Merge "Handle special case SQLite exception during upsert." into androidx-main
diff --git a/appsearch/appsearch-ktx/src/androidTest/java/AnnotationProcessorKtTest.kt b/appsearch/appsearch-ktx/src/androidTest/java/AnnotationProcessorKtTest.kt
index 85eb735..97dbfaf 100644
--- a/appsearch/appsearch-ktx/src/androidTest/java/AnnotationProcessorKtTest.kt
+++ b/appsearch/appsearch-ktx/src/androidTest/java/AnnotationProcessorKtTest.kt
@@ -150,9 +150,6 @@
         @Document.BytesProperty
         val arrUnboxByteArr: Array<ByteArray>,
 
-        @Document.BytesProperty
-        val boxByteArr: Array<Byte>,
-
         @Document.StringProperty
         val arrString: Array<String>,
 
@@ -227,7 +224,6 @@
             if (!arrBoxBoolean.contentEquals(other.arrBoxBoolean)) return false
             if (!arrUnboxBoolean.contentEquals(other.arrUnboxBoolean)) return false
             if (!arrUnboxByteArr.contentDeepEquals(other.arrUnboxByteArr)) return false
-            if (!boxByteArr.contentEquals(other.boxByteArr)) return false
             if (!arrString.contentEquals(other.arrString)) return false
             if (!arrCard.contentEquals(other.arrCard)) return false
             if (string != other.string) return false
@@ -297,7 +293,6 @@
             arrBoxInteger = arrayOf(4, 5),
             arrBoxLong = arrayOf(6L, 7L),
             arrString = arrayOf("cat", "dog"),
-            boxByteArr = arrayOf(8, 9),
             arrUnboxBoolean = booleanArrayOf(false, true),
             arrUnboxByteArr = arrayOf(byteArrayOf(0, 1), byteArrayOf(2, 3)),
             arrUnboxDouble = doubleArrayOf(1.0, 0.0),
diff --git a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/AnnotationProcessorTestBase.java b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/AnnotationProcessorTestBase.java
index 75051c2..5dc334e 100644
--- a/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/AnnotationProcessorTestBase.java
+++ b/appsearch/appsearch/src/androidTest/java/androidx/appsearch/app/AnnotationProcessorTestBase.java
@@ -159,8 +159,6 @@
         boolean[] mArrUnboxBoolean; // 2b
         @Document.BytesProperty
         byte[][] mArrUnboxByteArr;  // 2b
-        @Document.BytesProperty
-        Byte[] mBoxByteArr;         // 2a
         @Document.StringProperty
         String[] mArrString;        // 2b
         @Document.DocumentProperty
@@ -211,7 +209,6 @@
             assertThat(otherGift.mArrBoxLong).isEqualTo(this.mArrBoxLong);
             assertThat(otherGift.mArrBoxInteger).isEqualTo(this.mArrBoxInteger);
             assertThat(otherGift.mArrString).isEqualTo(this.mArrString);
-            assertThat(otherGift.mBoxByteArr).isEqualTo(this.mBoxByteArr);
             assertThat(otherGift.mArrUnboxBoolean).isEqualTo(this.mArrUnboxBoolean);
             assertThat(otherGift.mArrUnboxByteArr).isEqualTo(this.mArrUnboxByteArr);
             assertThat(otherGift.mArrUnboxDouble).isEqualTo(this.mArrUnboxDouble);
@@ -265,7 +262,6 @@
             gift.mArrBoxInteger = new Integer[]{4, 5};
             gift.mArrBoxLong = new Long[]{6L, 7L};
             gift.mArrString = new String[]{"cat", "dog"};
-            gift.mBoxByteArr = new Byte[]{8, 9};
             gift.mArrUnboxBoolean = new boolean[]{false, true};
             gift.mArrUnboxByteArr = new byte[][]{{0, 1}, {2, 3}};
             gift.mArrUnboxDouble = new double[]{1.0, 0.0};
@@ -339,8 +335,9 @@
     public void testAnnotationProcessor() throws Exception {
         //TODO(b/156296904) add test for int, float, GenericDocument, and class with
         // @Document annotation
-        mSession.setSchemaAsync(
-                new SetSchemaRequest.Builder().addDocumentClasses(Card.class, Gift.class).build())
+        mSession.setSchemaAsync(new SetSchemaRequest.Builder()
+                        .addDocumentClasses(Card.class, Gift.class)
+                        .build())
                 .get();
 
         // Create a Gift object and assign values.
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/AnnotatedGetterOrField.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/AnnotatedGetterOrField.java
new file mode 100644
index 0000000..712cb2b
--- /dev/null
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/AnnotatedGetterOrField.java
@@ -0,0 +1,564 @@
+/*
+ * 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.appsearch.compiler;
+
+import static androidx.appsearch.compiler.IntrospectionHelper.DOCUMENT_ANNOTATION_CLASS;
+import static androidx.appsearch.compiler.IntrospectionHelper.getDocumentAnnotation;
+import static androidx.appsearch.compiler.IntrospectionHelper.getPropertyType;
+import static androidx.appsearch.compiler.IntrospectionHelper.validateIsGetter;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appsearch.compiler.annotationwrapper.DataPropertyAnnotation;
+import androidx.appsearch.compiler.annotationwrapper.MetadataPropertyAnnotation;
+import androidx.appsearch.compiler.annotationwrapper.PropertyAnnotation;
+import androidx.appsearch.compiler.annotationwrapper.StringPropertyAnnotation;
+
+import com.google.auto.value.AutoValue;
+
+import java.util.Collection;
+import java.util.List;
+
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.type.ArrayType;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.Types;
+
+/**
+ * A getter or field annotated with a {@link PropertyAnnotation} annotation. For example,
+ *
+ * <pre>
+ * {@code
+ * @Document("MyEntity")
+ * public final class Entity {
+ *     @Document.Id
+ *     public String mId;
+ * //                ^^^
+ *
+ * // OR
+ *
+ *     @Document.StringProperty()
+ *     public String getName() {...}
+ * //                ^^^^^^^
+ * }
+ * }
+ * </pre>
+ */
+@AutoValue
+public abstract class AnnotatedGetterOrField {
+    /**
+     * Specifies whether the getter/field is assigned a collection or array or a single type.
+     *
+     * <p>Note: {@code byte[]} are treated specially such that
+     * <ul>
+     *     <li>{@code byte[]} is a primitive in icing and is treated as {@link #SINGLE}.</li>
+     *     <li>{@code Collection<byte[]>} is treated as a {@link #COLLECTION}.</li>
+     *     <li>{@code byte[][]} is treated as an {@link #ARRAY}.</li>
+     * </ul>
+     *
+     * The boxed {@link Byte} type is not supported by the AppSearch compiler at all.
+     */
+    public enum ElementTypeCategory {
+        SINGLE, COLLECTION, ARRAY
+    }
+
+    /**
+     * Creates a {@link AnnotatedGetterOrField} if the element is annotated with some
+     * {@link PropertyAnnotation}. Otherwise returns null.
+     */
+    @Nullable
+    public static AnnotatedGetterOrField tryCreateFor(
+            @NonNull Element element,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        requireNonNull(element);
+        requireNonNull(env);
+
+        AnnotationMirror annotation = getSingleAppSearchAnnotation(element);
+        if (annotation == null) {
+            return null;
+        }
+
+        MetadataPropertyAnnotation metadataPropertyAnnotation =
+                MetadataPropertyAnnotation.tryParse(annotation);
+        if (metadataPropertyAnnotation != null) {
+            return AnnotatedGetterOrField.create(metadataPropertyAnnotation, element, env);
+        }
+
+        String normalizedName = inferNormalizedName(element, env); // e.g. mField -> field
+        DataPropertyAnnotation dataPropertyAnnotation =
+                DataPropertyAnnotation.tryParse(
+                        annotation, /* defaultName= */normalizedName, new IntrospectionHelper(env));
+        if (dataPropertyAnnotation != null) {
+            return AnnotatedGetterOrField.create(dataPropertyAnnotation, element, env);
+        }
+
+        return null;
+    }
+
+    /**
+     * Creates a {@link AnnotatedGetterOrField} for a {@code getterOrField} annotated with the
+     * specified {@code annotation}.
+     */
+    @NonNull
+    public static AnnotatedGetterOrField create(
+            @NonNull PropertyAnnotation annotation,
+            @NonNull Element getterOrField,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        requireNonNull(annotation);
+        requireNonNull(getterOrField);
+        requireNonNull(env);
+        requireIsGetterOrField(getterOrField);
+
+        ElementTypeCategory typeCategory = inferTypeCategory(getterOrField, env);
+        AnnotatedGetterOrField annotatedGetterOrField =
+                new AutoValue_AnnotatedGetterOrField(
+                        annotation,
+                        getterOrField,
+                        typeCategory,
+                        /* componentType= */inferComponentType(getterOrField, typeCategory),
+                        /* normalizedName= */inferNormalizedName(getterOrField, env));
+
+        requireTypeMatchesAnnotation(annotatedGetterOrField, env);
+
+        return annotatedGetterOrField;
+    }
+
+    /**
+     * The annotation that the getter or field is annotated with.
+     */
+    @NonNull
+    public abstract PropertyAnnotation getAnnotation();
+
+    /**
+     * The annotated getter or field.
+     */
+    @NonNull
+    public abstract Element getElement();
+
+    /**
+     * The type-category of the getter or field.
+     *
+     * <p>Note: {@code byte[]} as treated specially as documented in {@link ElementTypeCategory}.
+     */
+    @NonNull
+    public abstract ElementTypeCategory getElementTypeCategory();
+
+    /**
+     * The field/getter's return type.
+     */
+    @NonNull
+    public TypeMirror getJvmType() {
+        return isGetter()
+                ? ((ExecutableElement) getElement()).getReturnType()
+                : getElement().asType();
+    }
+
+    /**
+     * The field/getter's return type if non-repeated, else the underlying element type.
+     *
+     * <p>For example, {@code String} for a field {@code String mName} and {@code int} for a field
+     * {@code int[] mNums}.
+     *
+     * <p>The one exception to this is {@code byte[]} where:
+     *
+     * <pre>
+     * {@code
+     * @BytesProperty bytes[] mField; // componentType: byte[]
+     * @BytesProperty bytes[][] mField; // componentType: byte[]
+     * @BytesProperty List<bytes [ ]> mField; // componentType: byte[]
+     * }
+     * </pre>
+     */
+    @NonNull
+    public abstract TypeMirror getComponentType();
+
+    /**
+     * The getter/field's jvm name e.g. {@code mId} or {@code getName}.
+     */
+    @NonNull
+    public String getJvmName() {
+        return getElement().getSimpleName().toString();
+    }
+
+    /**
+     * The normalized/stemmed {@link #getJvmName()}.
+     *
+     * <p>For example,
+     * <pre>
+     * {@code
+     * getName -> name
+     * mName -> name
+     * _name -> name
+     * name_ -> name
+     * isAwesome -> awesome
+     * }
+     * </pre>
+     */
+    @NonNull
+    public abstract String getNormalizedName();
+
+    /**
+     * Whether the {@link #getElement()} is a getter.
+     */
+    public boolean isGetter() {
+        return getElement().getKind() == ElementKind.METHOD;
+    }
+
+    /**
+     * Whether the {@link #getElement()} is a field.
+     */
+    public boolean isField() {
+        return getElement().getKind() == ElementKind.FIELD;
+    }
+
+    private static void requireIsGetterOrField(@NonNull Element element)
+            throws ProcessingException {
+        switch (element.getKind()) {
+            case FIELD:
+                return;
+            case METHOD:
+                ExecutableElement method = (ExecutableElement) element;
+                List<ProcessingException> errors = validateIsGetter(method);
+                if (!errors.isEmpty()) {
+                    ProcessingException err = new ProcessingException(
+                            "Failed to find a suitable getter for element \"%s\"".formatted(
+                                    method.getSimpleName()),
+                            method);
+                    err.addWarnings(errors);
+                    throw err;
+                }
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * Infers whether the getter/field returns a collection or array or neither.
+     *
+     * <p>Note: {@code byte[]} are treated specially as documented in {@link ElementTypeCategory}.
+     */
+    @NonNull
+    private static ElementTypeCategory inferTypeCategory(
+            @NonNull Element getterOrField,
+            @NonNull ProcessingEnvironment env) {
+        TypeMirror jvmType = getPropertyType(getterOrField);
+        Types typeUtils = env.getTypeUtils();
+        IntrospectionHelper helper = new IntrospectionHelper(env);
+        if (typeUtils.isAssignable(typeUtils.erasure(jvmType), helper.mCollectionType)) {
+            return ElementTypeCategory.COLLECTION;
+        } else if (jvmType.getKind() == TypeKind.ARRAY
+                && !typeUtils.isSameType(jvmType, helper.mBytePrimitiveArrayType)
+                && !typeUtils.isSameType(jvmType, helper.mByteBoxArrayType)) {
+            // byte[] has a native representation in Icing and should be considered a
+            // primitive by itself.
+            //
+            // byte[][], however, is considered repeated (ARRAY).
+            return ElementTypeCategory.ARRAY;
+        } else {
+            return ElementTypeCategory.SINGLE;
+        }
+    }
+
+    /**
+     * Infers the getter/field's return type if non-repeated, else the underlying element type.
+     *
+     * <p>For example, {@code String mField -> String} and {@code List<String> mField -> String}.
+     */
+    @NonNull
+    private static TypeMirror inferComponentType(
+            @NonNull Element getterOrField,
+            @NonNull ElementTypeCategory typeCategory) throws ProcessingException {
+        TypeMirror jvmType = getPropertyType(getterOrField);
+        switch (typeCategory) {
+            case SINGLE:
+                return jvmType;
+            case COLLECTION:
+                // e.g. List<T>
+                //           ^
+                List<? extends TypeMirror> typeArguments =
+                        ((DeclaredType) jvmType).getTypeArguments();
+                if (typeArguments.isEmpty()) {
+                    throw new ProcessingException(
+                            "Property is repeated but has no generic rawType", getterOrField);
+                }
+                return typeArguments.get(0);
+            case ARRAY:
+                return ((ArrayType) jvmType).getComponentType();
+            default:
+                throw new IllegalStateException("Unhandled type-category: " + typeCategory);
+        }
+    }
+
+    @NonNull
+    private static String inferNormalizedName(
+            @NonNull Element element,
+            @NonNull ProcessingEnvironment env) {
+        return element.getKind() == ElementKind.METHOD
+                ? inferNormalizedMethodName(element, env)
+                : inferNormalizedFieldName(element);
+    }
+
+    /**
+     * Makes sure the getter/field's JVM type matches the type expected by the
+     * {@link PropertyAnnotation}.
+     */
+    private static void requireTypeMatchesAnnotation(
+            @NonNull AnnotatedGetterOrField getterOrField,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        PropertyAnnotation annotation = getterOrField.getAnnotation();
+        switch (annotation.getPropertyKind()) {
+            case METADATA_PROPERTY:
+                requireTypeMatchesMetadataPropertyAnnotation(
+                        getterOrField, (MetadataPropertyAnnotation) annotation, env);
+                break;
+            case DATA_PROPERTY:
+                requireTypeMatchesDataPropertyAnnotation(
+                        getterOrField, (DataPropertyAnnotation) annotation, env);
+                break;
+            default:
+                throw new IllegalStateException("Unhandled annotation: " + annotation);
+        }
+    }
+
+    /**
+     * Returns the only {@code @Document.*} annotation on the element e.g.
+     * {@code @Document.StringProperty}.
+     *
+     * <p>Returns null if no such annotation exists on the element.
+     *
+     * @throws ProcessingException If the element is annotated with more than one of such
+     *                             annotations.
+     */
+    @Nullable
+    private static AnnotationMirror getSingleAppSearchAnnotation(
+            @NonNull Element element) throws ProcessingException {
+        // @Document.* annotation
+        List<? extends AnnotationMirror> annotations =
+                element.getAnnotationMirrors().stream()
+                        .filter(ann -> ann.getAnnotationType().toString().startsWith(
+                                DOCUMENT_ANNOTATION_CLASS)).toList();
+        if (annotations.isEmpty()) {
+            return null;
+        }
+        if (annotations.size() > 1) {
+            throw new ProcessingException("Cannot use multiple @Document.* annotations",
+                    element);
+        }
+        return annotations.get(0);
+    }
+
+    @NonNull
+    private static String inferNormalizedMethodName(
+            @NonNull Element method, @NonNull ProcessingEnvironment env) {
+        String methodName = method.getSimpleName().toString();
+        IntrospectionHelper helper = new IntrospectionHelper(env);
+        // String getName() -> name
+        if (methodName.startsWith("get") && methodName.length() > 3
+                && Character.isUpperCase(methodName.charAt(3))) {
+            return methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
+        }
+
+        // String isAwesome() -> awesome
+        if (helper.isFieldOfBooleanType(method) && methodName.startsWith("is")
+                && methodName.length() > 2) {
+            return methodName.substring(2, 3).toLowerCase() + methodName.substring(3);
+        }
+
+        // Assume the method's name is the normalized name as well: String name() -> name
+        return methodName;
+    }
+
+    @NonNull
+    private static String inferNormalizedFieldName(@NonNull Element field) {
+        String fieldName = field.getSimpleName().toString();
+        if (fieldName.length() < 2) {
+            return fieldName;
+        }
+
+        // String mName -> name
+        if (fieldName.charAt(0) == 'm' && Character.isUpperCase(fieldName.charAt(1))) {
+            return fieldName.substring(1, 2).toLowerCase() + fieldName.substring(2);
+        }
+
+        // String _name -> name
+        if (fieldName.charAt(0) == '_'
+                && fieldName.charAt(1) != '_'
+                && Character.isLowerCase(fieldName.charAt(1))) {
+            return fieldName.substring(1);
+        }
+
+        // String name_ -> name
+        if (fieldName.charAt(fieldName.length() - 1) == '_'
+                && fieldName.charAt(fieldName.length() - 2) != '_') {
+            return fieldName.substring(0, fieldName.length() - 1);
+        }
+
+        // Assume the field's name is the normalize name as well: String name -> name
+        return fieldName;
+    }
+
+    /**
+     * Makes sure the getter/field's JVM type matches the type expected by the
+     * {@link MetadataPropertyAnnotation}.
+     *
+     * <p>For example, fields annotated with {@code @Document.Score} must be of type {@code int}
+     * or {@link Integer}.
+     */
+    private static void requireTypeMatchesMetadataPropertyAnnotation(
+            @NonNull AnnotatedGetterOrField getterOrField,
+            @NonNull MetadataPropertyAnnotation annotation,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        IntrospectionHelper helper = new IntrospectionHelper(env);
+        switch (annotation) {
+            case ID: // fall-through
+            case NAMESPACE:
+                requireTypeIsOneOf(
+                        getterOrField, List.of(helper.mStringType), env, /* allowRepeated= */false);
+                break;
+            case TTL_MILLIS: // fall-through
+            case CREATION_TIMESTAMP_MILLIS:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mLongPrimitiveType, helper.mIntPrimitiveType,
+                                helper.mLongBoxType, helper.mIntegerBoxType),
+                        env,
+                        /* allowRepeated= */false);
+                break;
+            case SCORE:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mIntPrimitiveType, helper.mIntegerBoxType),
+                        env,
+                        /* allowRepeated= */false);
+                break;
+            default:
+                throw new IllegalStateException("Unhandled annotation: " + annotation);
+        }
+    }
+
+    /**
+     * Makes sure the getter/field's JVM type matches the type expected by the
+     * {@link DataPropertyAnnotation}.
+     *
+     * <p>For example, fields annotated with {@link StringPropertyAnnotation} must be of type
+     * {@link String} or a collection or array of {@link String}s.
+     */
+    private static void requireTypeMatchesDataPropertyAnnotation(
+            @NonNull AnnotatedGetterOrField getterOrField,
+            @NonNull DataPropertyAnnotation annotation,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        IntrospectionHelper helper = new IntrospectionHelper(env);
+        switch (annotation.getDataPropertyKind()) {
+            case STRING_PROPERTY:
+                requireTypeIsOneOf(
+                        getterOrField, List.of(helper.mStringType), env, /* allowRepeated= */true);
+                break;
+            case DOCUMENT_PROPERTY:
+                requireTypeIsSomeDocumentClass(getterOrField, env);
+                break;
+            case LONG_PROPERTY:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mLongPrimitiveType, helper.mIntPrimitiveType,
+                                helper.mLongBoxType, helper.mIntegerBoxType),
+                        env,
+                        /* allowRepeated= */true);
+                break;
+            case DOUBLE_PROPERTY:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mDoublePrimitiveType, helper.mFloatPrimitiveType,
+                                helper.mDoubleBoxType, helper.mFloatBoxType),
+                        env,
+                        /* allowRepeated= */true);
+                break;
+            case BOOLEAN_PROPERTY:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mBooleanPrimitiveType, helper.mBooleanBoxType),
+                        env,
+                        /* allowRepeated= */true);
+                break;
+            case BYTES_PROPERTY:
+                requireTypeIsOneOf(
+                        getterOrField,
+                        List.of(helper.mBytePrimitiveArrayType),
+                        env,
+                        /* allowRepeated= */true);
+                break;
+            default:
+                throw new IllegalStateException("Unhandled annotation: " + annotation);
+        }
+    }
+
+    /**
+     * Makes sure the getter/field's type is one of the expected types.
+     *
+     * <p>If {@code allowRepeated} is true, also allows the getter/field's type to be an array or
+     * collection of any of the expected types.
+     */
+    private static void requireTypeIsOneOf(
+            @NonNull AnnotatedGetterOrField getterOrField,
+            @NonNull Collection<TypeMirror> expectedTypes,
+            @NonNull ProcessingEnvironment env,
+            boolean allowRepeated) throws ProcessingException {
+        Types typeUtils = env.getTypeUtils();
+        TypeMirror target = allowRepeated
+                ? getterOrField.getComponentType() : getterOrField.getJvmType();
+        boolean isValid = expectedTypes.stream()
+                .anyMatch(expectedType -> typeUtils.isSameType(expectedType, target));
+        if (!isValid) {
+            String error = "@"
+                    + getterOrField.getAnnotation().getSimpleClassName()
+                    + " must only be placed on a getter/field of type "
+                    + (allowRepeated ? "or array or collection of " : "")
+                    + expectedTypes.stream().map(TypeMirror::toString).collect(joining("|"));
+            throw new ProcessingException(error, getterOrField.getElement());
+        }
+    }
+
+    /**
+     * Makes sure the getter/field is assigned a type annotated with {@code @Document}.
+     *
+     * <p>Allows for arrays and collections of such a type as well.
+     */
+    private static void requireTypeIsSomeDocumentClass(
+            @NonNull AnnotatedGetterOrField annotatedGetterOrField,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        TypeMirror componentType = annotatedGetterOrField.getComponentType();
+        if (componentType.getKind() == TypeKind.DECLARED) {
+            Element element = env.getTypeUtils().asElement(componentType);
+            if (element.getKind() == ElementKind.CLASS && getDocumentAnnotation(element) != null) {
+                return;
+            }
+        }
+        throw new ProcessingException(
+                "Invalid type for @DocumentProperty. Must be another class "
+                        + "annotated with @Document (or collection or array of)",
+                annotatedGetterOrField.getElement());
+    }
+}
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/DocumentModel.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/DocumentModel.java
index e9df0dc..925d0bb 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/DocumentModel.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/DocumentModel.java
@@ -19,11 +19,17 @@
 import static androidx.appsearch.compiler.IntrospectionHelper.DOCUMENT_ANNOTATION_CLASS;
 import static androidx.appsearch.compiler.IntrospectionHelper.generateClassHierarchy;
 import static androidx.appsearch.compiler.IntrospectionHelper.getDocumentAnnotation;
+import static androidx.appsearch.compiler.IntrospectionHelper.validateIsGetter;
+
+import static java.util.stream.Collectors.groupingBy;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
 import androidx.appsearch.compiler.IntrospectionHelper.PropertyClass;
+import androidx.appsearch.compiler.annotationwrapper.DataPropertyAnnotation;
+import androidx.appsearch.compiler.annotationwrapper.MetadataPropertyAnnotation;
+import androidx.appsearch.compiler.annotationwrapper.PropertyAnnotation;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -35,7 +41,9 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
+import java.util.function.Predicate;
 
 import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.AnnotationMirror;
@@ -111,6 +119,8 @@
     private TypeElement mBuilderClass = null;
     private Set<ExecutableElement> mBuilderProducers = new LinkedHashSet<>();
 
+    private final List<AnnotatedGetterOrField> mAnnotatedGettersAndFields;
+
     private DocumentModel(
             @NonNull ProcessingEnvironment env,
             @NonNull TypeElement clazz,
@@ -127,6 +137,18 @@
         mQualifiedDocumentClassName = generatedAutoValueElement != null
                 ? generatedAutoValueElement.getQualifiedName().toString()
                 : clazz.getQualifiedName().toString();
+        mAnnotatedGettersAndFields = scanAnnotatedGettersAndFields(clazz, env);
+
+        requireNoDuplicateMetadataProperties();
+        requireGetterOrFieldMatchingPredicate(
+                getterOrField -> getterOrField.getAnnotation() == MetadataPropertyAnnotation.ID,
+                /* errorMessage= */"All @Document classes must have exactly one field annotated "
+                        + "with @Id");
+        requireGetterOrFieldMatchingPredicate(
+                getterOrField ->
+                        getterOrField.getAnnotation() == MetadataPropertyAnnotation.NAMESPACE,
+                /* errorMessage= */"All @Document classes must have exactly one field annotated "
+                        + "with @Namespace");
 
         // Scan methods and constructors. We will need this info when processing fields to
         // make sure the fields can be get and set.
@@ -145,8 +167,9 @@
      * respectively.
      *
      * @throws ProcessingException if there are more than one elements annotated with
-     * {@code @Document.BuilderProducer}, or if the builder producer element is not a visible static
-     * method or a class.
+     *                             {@code @Document.BuilderProducer}, or if the builder producer
+     *                             element is not a visible static
+     *                             method or a class.
      */
     private void extractBuilderProducer(TypeElement typeElement)
             throws ProcessingException {
@@ -196,6 +219,65 @@
         }
     }
 
+    private static List<AnnotatedGetterOrField> scanAnnotatedGettersAndFields(
+            @NonNull TypeElement clazz,
+            @NonNull ProcessingEnvironment env) throws ProcessingException {
+        AnnotatedGetterAndFieldAccumulator accumulator = new AnnotatedGetterAndFieldAccumulator();
+        for (TypeElement type : generateClassHierarchy(clazz)) {
+            for (Element enclosedElement : type.getEnclosedElements()) {
+                AnnotatedGetterOrField getterOrField =
+                        AnnotatedGetterOrField.tryCreateFor(enclosedElement, env);
+                if (getterOrField == null) {
+                    continue;
+                }
+                accumulator.add(getterOrField);
+            }
+        }
+        return accumulator.getAccumulatedGettersAndFields();
+    }
+
+    /**
+     * Makes sure {@link #mAnnotatedGettersAndFields} does not contain two getters/fields
+     * annotated with the same metadata annotation e.g. it doesn't make sense for a document to
+     * have two {@code @Document.Id}s.
+     */
+    private void requireNoDuplicateMetadataProperties() throws ProcessingException {
+        Map<MetadataPropertyAnnotation, List<AnnotatedGetterOrField>> annotationToGettersAndFields =
+                mAnnotatedGettersAndFields.stream()
+                        .filter(getterOrField ->
+                                getterOrField.getAnnotation().getPropertyKind()
+                                        == PropertyAnnotation.Kind.METADATA_PROPERTY)
+                        .collect(groupingBy((getterOrField) ->
+                                (MetadataPropertyAnnotation) getterOrField.getAnnotation()));
+        for (Map.Entry<MetadataPropertyAnnotation, List<AnnotatedGetterOrField>> entry :
+                annotationToGettersAndFields.entrySet()) {
+            MetadataPropertyAnnotation annotation = entry.getKey();
+            List<AnnotatedGetterOrField> gettersAndFields = entry.getValue();
+            if (gettersAndFields.size() > 1) {
+                // Can show the error on any of the duplicates. Just pick the first first.
+                throw new ProcessingException(
+                        "Duplicate member annotated with @" + annotation.getSimpleClassName(),
+                        gettersAndFields.get(0).getElement());
+            }
+        }
+    }
+
+    /**
+     * Makes sure {@link #mAnnotatedGettersAndFields} contains a getter/field that matches the
+     * predicate.
+     *
+     * @throws ProcessingException with the error message if no match.
+     */
+    private void requireGetterOrFieldMatchingPredicate(
+            @NonNull Predicate<AnnotatedGetterOrField> predicate,
+            @NonNull String errorMessage) throws ProcessingException {
+        Optional<AnnotatedGetterOrField> annotatedGetterOrField =
+                mAnnotatedGettersAndFields.stream().filter(predicate).findFirst();
+        if (annotatedGetterOrField.isEmpty()) {
+            throw new ProcessingException(errorMessage, mClass);
+        }
+    }
+
     private Set<ExecutableElement> extractCreationMethods(TypeElement typeElement)
             throws ProcessingException {
         extractBuilderProducer(typeElement);
@@ -294,6 +376,19 @@
         return Collections.unmodifiableMap(mAllAppSearchElements);
     }
 
+    /**
+     * Returns all getters/fields (declared or inherited) annotated with some
+     * {@link PropertyAnnotation}.
+     */
+    @NonNull
+    public List<AnnotatedGetterOrField> getAnnotatedGettersAndFields() {
+        return mAnnotatedGettersAndFields;
+    }
+
+    /**
+     * @deprecated Use {@link #getAnnotatedGettersAndFields()} instead.
+     */
+    @Deprecated
     @NonNull
     public Map<String, Element> getPropertyElements() {
         return Collections.unmodifiableMap(mPropertyElements);
@@ -331,7 +426,11 @@
      *
      * <p>This is usually the name of the field in Java, but may be changed if the developer
      * specifies a different 'name' parameter in the annotation.
+     *
+     * @deprecated Use {@link #getAnnotatedGettersAndFields()} and
+     * {@link DataPropertyAnnotation#getName()} ()} instead.
      */
+    @Deprecated
     @NonNull
     public String getPropertyName(@NonNull Element property) throws ProcessingException {
         AnnotationMirror annotation = getPropertyAnnotation(property);
@@ -348,7 +447,10 @@
      * annotations.
      *
      * @throws ProcessingException if no AppSearch property annotation is found.
+     * @deprecated Use {@link #getAnnotatedGettersAndFields()} and
+     * {@link AnnotatedGetterOrField#getAnnotation()} instead.
      */
+    @Deprecated
     @NonNull
     public AnnotationMirror getPropertyAnnotation(@NonNull Element element)
             throws ProcessingException {
@@ -392,7 +494,10 @@
      * Scan the annotations of a field to determine the fields type and handle it accordingly
      *
      * @param childElement the member of class elements currently being scanned
+     * @deprecated Rely on {@link #mAnnotatedGettersAndFields} instead of
+     * {@link #mAllAppSearchElements} and {@link #mSpecialFieldNames}.
      */
+    @Deprecated
     private void scanAnnotatedField(@NonNull Element childElement) throws ProcessingException {
         String fieldName = childElement.getSimpleName().toString();
 
@@ -787,14 +892,9 @@
                     mHelper.isFieldOfBooleanType(element)
                             && methodName.equals("is" + methodNameSuffix))
             ) {
-                if (method.getModifiers().contains(Modifier.PRIVATE)) {
-                    e.addWarning(new ProcessingException(
-                            "Getter cannot be used: private visibility", method));
-                    continue;
-                }
-                if (!method.getParameters().isEmpty()) {
-                    e.addWarning(new ProcessingException(
-                            "Getter cannot be used: should take no parameters", method));
+                List<ProcessingException> errors = validateIsGetter(method);
+                if (!errors.isEmpty()) {
+                    e.addWarnings(errors);
                     continue;
                 }
                 // Found one!
@@ -967,4 +1067,258 @@
         // Documents don't need an explicit name annotation, can use the class name
         return rootDocumentClass.getSimpleName().toString();
     }
+
+    /**
+     * Accumulates and de-duplicates {@link AnnotatedGetterOrField}s within a class hierarchy and
+     * ensures all of the following:
+     *
+     * <ol>
+     *     <li>
+     *         The same getter/field doesn't appear in the class hierarchy with different
+     *         annotation types e.g.
+     *
+     *         <pre>
+     *         {@code
+     *         @Document
+     *         class Parent {
+     *             @Document.StringProperty
+     *             public String getProp();
+     *         }
+     *
+     *         @Document
+     *         class Child extends Parent {
+     *             @Document.Id
+     *             public String getProp();
+     *         }
+     *         }
+     *         </pre>
+     *     </li>
+     *     <li>
+     *         The same getter/field doesn't appear twice with different serialized names e.g.
+     *
+     *         <pre>
+     *         {@code
+     *         @Document
+     *         class Parent {
+     *             @Document.StringProperty("foo")
+     *             public String getProp();
+     *         }
+     *
+     *         @Document
+     *         class Child extends Parent {
+     *             @Document.StringProperty("bar")
+     *             public String getProp();
+     *         }
+     *         }
+     *         </pre>
+     *     </li>
+     *     <li>
+     *         The same serialized name doesn't appear on two separate getters/fields e.g.
+     *
+     *         <pre>
+     *         {@code
+     *         @Document
+     *         class Gift {
+     *             @Document.StringProperty("foo")
+     *             String mField;
+     *
+     *             @Document.LongProperty("foo")
+     *             Long getProp();
+     *         }
+     *         }
+     *         </pre>
+     *     </li>
+     *     <li>
+     *         Two annotated element do not have the same normalized name because this hinders with
+     *         downstream logic that tries to infer creation methods e.g.
+     *
+     *         <pre>
+     *         {@code
+     *         @Document
+     *         class Gift {
+     *             @Document.StringProperty
+     *             String mFoo;
+     *
+     *             @Document.StringProperty
+     *             String getFoo() {...}
+     *             void setFoo(String value) {...}
+     *         }
+     *         }
+     *         </pre>
+     *     </li>
+     * </ol>
+     */
+    private static final class AnnotatedGetterAndFieldAccumulator {
+        private final Map<String, AnnotatedGetterOrField> mJvmNameToGetterOrField =
+                new LinkedHashMap<>();
+        private final Map<String, AnnotatedGetterOrField> mSerializedNameToGetterOrField =
+                new HashMap<>();
+        private final Map<String, AnnotatedGetterOrField> mNormalizedNameToGetterOrField =
+                new HashMap<>();
+
+        AnnotatedGetterAndFieldAccumulator() {
+        }
+
+        /**
+         * Adds the {@link AnnotatedGetterOrField} to the accumulator.
+         *
+         * <p>{@link AnnotatedGetterOrField} that appear again are considered to be overridden
+         * versions and replace the older ones.
+         *
+         * <p>Hence, this method should be called with {@link AnnotatedGetterOrField}s from the
+         * least specific types to the most specific type.
+         */
+        void add(@NonNull AnnotatedGetterOrField getterOrField) throws ProcessingException {
+            String jvmName = getterOrField.getJvmName();
+            AnnotatedGetterOrField existingGetterOrField = mJvmNameToGetterOrField.get(jvmName);
+
+            if (existingGetterOrField == null) {
+                // First time we're seeing this getter or field
+                mJvmNameToGetterOrField.put(jvmName, getterOrField);
+
+                requireUniqueNormalizedName(getterOrField);
+                mNormalizedNameToGetterOrField.put(
+                        getterOrField.getNormalizedName(), getterOrField);
+
+                if (hasDataPropertyAnnotation(getterOrField)) {
+                    requireSerializedNameNeverSeenBefore(getterOrField);
+                    mSerializedNameToGetterOrField.put(
+                            getSerializedName(getterOrField), getterOrField);
+                }
+            } else {
+                // Seen this getter or field before. It showed up again because of overriding.
+                requireAnnotationTypeIsConsistent(existingGetterOrField, getterOrField);
+                // Replace the old entries
+                mJvmNameToGetterOrField.put(jvmName, getterOrField);
+                mNormalizedNameToGetterOrField.put(
+                        getterOrField.getNormalizedName(), getterOrField);
+
+                if (hasDataPropertyAnnotation(getterOrField)) {
+                    requireSerializedNameIsConsistent(existingGetterOrField, getterOrField);
+                    // Replace the old entry
+                    mSerializedNameToGetterOrField.put(
+                            getSerializedName(getterOrField), getterOrField);
+                }
+            }
+        }
+
+        @NonNull
+        List<AnnotatedGetterOrField> getAccumulatedGettersAndFields() {
+            return mJvmNameToGetterOrField.values().stream().toList();
+        }
+
+        /**
+         * Makes sure the getter/field's normalized name either never appeared before, or if it did,
+         * did so for the same getter/field and re-appeared likely because of overriding.
+         */
+        private void requireUniqueNormalizedName(
+                @NonNull AnnotatedGetterOrField getterOrField) throws ProcessingException {
+            AnnotatedGetterOrField existingGetterOrField =
+                    mNormalizedNameToGetterOrField.get(getterOrField.getNormalizedName());
+            if (existingGetterOrField == null) {
+                // Never seen this normalized name before
+                return;
+            }
+            if (existingGetterOrField.getJvmName().equals(getterOrField.getJvmName())) {
+                // Same getter/field appeared again (likely because of overriding). Ok.
+                return;
+            }
+            throw new ProcessingException(
+                    ("Normalized name \"%s\" is already taken up by pre-existing %s. "
+                            + "Please rename this getter/field to something else.").formatted(
+                            getterOrField.getNormalizedName(),
+                            createSignatureString(existingGetterOrField)),
+                    getterOrField.getElement());
+        }
+
+        /**
+         * Makes sure a new getter/field is never annotated with a serialized name that is
+         * already given to some other getter/field.
+         *
+         * <p>Assumes the getter/field is annotated with a {@link DataPropertyAnnotation}.
+         */
+        private void requireSerializedNameNeverSeenBefore(
+                @NonNull AnnotatedGetterOrField getterOrField) throws ProcessingException {
+            String serializedName = getSerializedName(getterOrField);
+            AnnotatedGetterOrField existingGetterOrField =
+                    mSerializedNameToGetterOrField.get(serializedName);
+            if (existingGetterOrField != null) {
+                throw new ProcessingException(
+                        "Cannot give property the name '%s' because it is already used for %s"
+                                .formatted(serializedName, existingGetterOrField.getJvmName()),
+                        getterOrField.getElement());
+            }
+        }
+
+        /**
+         * Returns the serialized name for the corresponding property in the database.
+         *
+         * <p>Assumes the getter/field is annotated with a {@link DataPropertyAnnotation} to pull
+         * the serialized name out of the annotation
+         * e.g. {@code @Document.StringProperty("serializedName")}.
+         */
+        @NonNull
+        private static String getSerializedName(@NonNull AnnotatedGetterOrField getterOrField) {
+            DataPropertyAnnotation annotation =
+                    (DataPropertyAnnotation) getterOrField.getAnnotation();
+            return annotation.getName();
+        }
+
+        private static boolean hasDataPropertyAnnotation(
+                @NonNull AnnotatedGetterOrField getterOrField) {
+            PropertyAnnotation annotation = getterOrField.getAnnotation();
+            return annotation.getPropertyKind() == PropertyAnnotation.Kind.DATA_PROPERTY;
+        }
+
+        /**
+         * Makes sure the annotation type didn't change when overriding e.g.
+         * {@code @StringProperty -> @Id}.
+         */
+        private static void requireAnnotationTypeIsConsistent(
+                @NonNull AnnotatedGetterOrField existingGetterOrField,
+                @NonNull AnnotatedGetterOrField overriddenGetterOfField)
+                throws ProcessingException {
+            PropertyAnnotation existingAnnotation = existingGetterOrField.getAnnotation();
+            PropertyAnnotation overriddenAnnotation = overriddenGetterOfField.getAnnotation();
+            if (!existingAnnotation.getQualifiedClassName().equals(
+                    overriddenAnnotation.getQualifiedClassName())) {
+                throw new ProcessingException(
+                        ("Property type must stay consistent when overriding annotated members "
+                                + "but changed from @%s -> @%s").formatted(
+                                existingAnnotation.getSimpleClassName(),
+                                overriddenAnnotation.getSimpleClassName()),
+                        overriddenGetterOfField.getElement());
+            }
+        }
+
+        /**
+         * Makes sure the serialized name didn't change when overriding.
+         *
+         * <p>Assumes the getter/field is annotated with a {@link DataPropertyAnnotation}.
+         */
+        private static void requireSerializedNameIsConsistent(
+                @NonNull AnnotatedGetterOrField existingGetterOrField,
+                @NonNull AnnotatedGetterOrField overriddenGetterOrField)
+                throws ProcessingException {
+            String existingSerializedName = getSerializedName(existingGetterOrField);
+            String overriddenSerializedName = getSerializedName(overriddenGetterOrField);
+            if (!existingSerializedName.equals(overriddenSerializedName)) {
+                throw new ProcessingException(
+                        ("Property name within the annotation must stay consistent when overriding "
+                                + "annotated members but changed from '%s' -> '%s'".formatted(
+                                existingSerializedName, overriddenSerializedName)),
+                        overriddenGetterOrField.getElement());
+            }
+        }
+
+        @NonNull
+        private static String createSignatureString(@NonNull AnnotatedGetterOrField getterOrField) {
+            return getterOrField.getJvmType()
+                    + " "
+                    + getterOrField.getElement().getEnclosingElement().getSimpleName()
+                    + "#"
+                    + getterOrField.getJvmName()
+                    + (getterOrField.isGetter() ? "()" : "");
+        }
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/IntrospectionHelper.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/IntrospectionHelper.java
index bc30992..420bd35 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/IntrospectionHelper.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/IntrospectionHelper.java
@@ -41,6 +41,7 @@
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ElementKind;
 import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.ArrayType;
 import javax.lang.model.type.DeclaredType;
@@ -260,6 +261,29 @@
         return new ArrayList<>(hierarchy);
     }
 
+    /**
+     * Checks if a method is a valid getter and returns any errors.
+     *
+     * <p>Returns an empty list if no errors i.e. the method is a valid getter.
+     */
+    @NonNull
+    public static List<ProcessingException> validateIsGetter(@NonNull ExecutableElement method) {
+        List<ProcessingException> errors = new ArrayList<>();
+        if (!method.getParameters().isEmpty()) {
+            errors.add(new ProcessingException(
+                    "Getter cannot be used: should take no parameters", method));
+        }
+        if (method.getModifiers().contains(Modifier.PRIVATE)) {
+            errors.add(new ProcessingException(
+                    "Getter cannot be used: private visibility", method));
+        }
+        if (method.getModifiers().contains(Modifier.STATIC)) {
+            errors.add(new ProcessingException(
+                    "Getter cannot be used: must not be static", method));
+        }
+        return errors;
+    }
+
     private static void generateClassHierarchyHelper(@NonNull TypeElement leafElement,
             @NonNull TypeElement currentClass, @NonNull Deque<TypeElement> hierarchy,
             @NonNull Set<TypeElement> visited)
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/ProcessingException.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/ProcessingException.java
index d1bd836..c239c35 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/ProcessingException.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/ProcessingException.java
@@ -20,6 +20,7 @@
 import androidx.annotation.RestrictTo;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 
 import javax.annotation.processing.Messager;
@@ -49,6 +50,10 @@
         mWarnings.add(warning);
     }
 
+    public void addWarnings(@NonNull Collection<ProcessingException> warnings) {
+        mWarnings.addAll(warnings);
+    }
+
     public void printDiagnostic(Messager messager) {
         printDiagnostic(messager, Diagnostic.Kind.ERROR);
     }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BooleanPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BooleanPropertyAnnotation.java
index 0d84e17..f4fd047 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BooleanPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BooleanPropertyAnnotation.java
@@ -47,4 +47,10 @@
         return new AutoValue_BooleanPropertyAnnotation(
                 name.isEmpty() ? defaultName : name, (boolean) annotationParams.get("required"));
     }
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.BOOLEAN_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BytesPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BytesPropertyAnnotation.java
index 35cc428..b367e2f 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BytesPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/BytesPropertyAnnotation.java
@@ -47,4 +47,10 @@
         return new AutoValue_BytesPropertyAnnotation(
                 name.isEmpty() ? defaultName : name, (boolean) annotationParams.get("required"));
     }
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.BYTES_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DataPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DataPropertyAnnotation.java
index 245072a..74ee9b9 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DataPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DataPropertyAnnotation.java
@@ -38,6 +38,11 @@
  * </ul>
  */
 public abstract class DataPropertyAnnotation implements PropertyAnnotation {
+    public enum Kind {
+        STRING_PROPERTY, DOCUMENT_PROPERTY, LONG_PROPERTY, DOUBLE_PROPERTY, BOOLEAN_PROPERTY,
+        BYTES_PROPERTY
+    }
+
     @NonNull
     private final String mSimpleClassName;
 
@@ -92,4 +97,16 @@
     public final String getSimpleClassName() {
         return mSimpleClassName;
     }
+
+    @NonNull
+    @Override
+    public final PropertyAnnotation.Kind getPropertyKind() {
+        return PropertyAnnotation.Kind.DATA_PROPERTY;
+    }
+
+    /**
+     * The {@link Kind} of {@link DataPropertyAnnotation}.
+     */
+    @NonNull
+    public abstract Kind getDataPropertyKind();
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DocumentPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DocumentPropertyAnnotation.java
index 53c2523..732d891 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DocumentPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DocumentPropertyAnnotation.java
@@ -54,4 +54,10 @@
      * Specifies whether fields in the nested document should be indexed.
      */
     public abstract boolean shouldIndexNestedProperties();
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.DOCUMENT_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DoublePropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DoublePropertyAnnotation.java
index 1a85e16..2b14892 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DoublePropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/DoublePropertyAnnotation.java
@@ -47,4 +47,10 @@
         return new AutoValue_DoublePropertyAnnotation(
                 name.isEmpty() ? defaultName : name, (boolean) annotationParams.get("required"));
     }
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.DOUBLE_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/LongPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/LongPropertyAnnotation.java
index 2f66b04..c519ee8 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/LongPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/LongPropertyAnnotation.java
@@ -54,4 +54,10 @@
      * Specifies how a property should be indexed.
      */
     public abstract int getIndexingType();
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.LONG_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/MetadataPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/MetadataPropertyAnnotation.java
index 9253048..bb9f1ce 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/MetadataPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/MetadataPropertyAnnotation.java
@@ -60,5 +60,11 @@
     public String getSimpleClassName() {
         return mSimpleClassName;
     }
+
+    @Override
+    @NonNull
+    public PropertyAnnotation.Kind getPropertyKind() {
+        return Kind.METADATA_PROPERTY;
+    }
 }
 
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/PropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/PropertyAnnotation.java
index 41cf474..b46847b 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/PropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/PropertyAnnotation.java
@@ -30,6 +30,10 @@
  * </ul>
  */
 public interface PropertyAnnotation {
+    enum Kind {
+        METADATA_PROPERTY, DATA_PROPERTY
+    }
+
     /**
      * The annotation class' simple name.
      *
@@ -48,4 +52,10 @@
     default String getQualifiedClassName() {
         return DOCUMENT_ANNOTATION_CLASS + "." + getSimpleClassName();
     }
+
+    /**
+     * The {@link Kind} of {@link PropertyAnnotation}.
+     */
+    @NonNull
+    Kind getPropertyKind();
 }
diff --git a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/StringPropertyAnnotation.java b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/StringPropertyAnnotation.java
index ddcdf8e..7891344 100644
--- a/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/StringPropertyAnnotation.java
+++ b/appsearch/compiler/src/main/java/androidx/appsearch/compiler/annotationwrapper/StringPropertyAnnotation.java
@@ -66,4 +66,10 @@
      * Specifies how a property should be processed so that the document can be joined.
      */
     public abstract int getJoinableValueType();
+
+    @NonNull
+    @Override
+    public final Kind getDataPropertyKind() {
+        return Kind.STRING_PROPERTY;
+    }
 }
diff --git a/appsearch/compiler/src/test/java/androidx/appsearch/compiler/AppSearchCompilerTest.java b/appsearch/compiler/src/test/java/androidx/appsearch/compiler/AppSearchCompilerTest.java
index d10c4bb..92e552c 100644
--- a/appsearch/compiler/src/test/java/androidx/appsearch/compiler/AppSearchCompilerTest.java
+++ b/appsearch/compiler/src/test/java/androidx/appsearch/compiler/AppSearchCompilerTest.java
@@ -92,7 +92,7 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "contains multiple fields annotated @Id");
+                "Duplicate member annotated with @Id");
     }
 
     @Test
@@ -166,7 +166,8 @@
                         + "  }\n"
                         + "}\n");
         assertThat(specialFieldReassigned).hadErrorContaining(
-                "Non-annotated field overriding special annotated fields named: id");
+                "Property type must stay consistent when overriding annotated "
+                        + "members but changed from @Id -> @StringProperty");
 
         Compilation nonAnnotatedFieldHasSameName = compile(
                 "@Document\n"
@@ -211,7 +212,7 @@
                         + "  public boolean getBadId() { return badId; }\n"
                         + "}\n");
         assertThat(idCollision).hadErrorContaining(
-                "Class hierarchy contains multiple fields annotated @Id");
+                "Duplicate member annotated with @Id");
 
         Compilation nsCollision = compile(
                 "@Document\n"
@@ -232,7 +233,7 @@
                         + "  }\n"
                         + "}\n");
         assertThat(nsCollision).hadErrorContaining(
-                "Class hierarchy contains multiple fields annotated @Namespace");
+                "Duplicate member annotated with @Namespace");
     }
 
     @Test
@@ -456,7 +457,7 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "contains multiple fields annotated @CreationTimestampMillis");
+                "Duplicate member annotated with @CreationTimestampMillis");
     }
 
     @Test
@@ -482,7 +483,7 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "contains multiple fields annotated @Namespace");
+                "Duplicate member annotated with @Namespace");
     }
 
     @Test
@@ -497,7 +498,7 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "contains multiple fields annotated @TtlMillis");
+                "Duplicate member annotated with @TtlMillis");
     }
 
     @Test
@@ -512,7 +513,7 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "contains multiple fields annotated @Score");
+                "Duplicate member annotated with @Score");
     }
 
     @Test
@@ -1223,7 +1224,6 @@
                         + "  @BooleanProperty Boolean[] arrBoxBoolean;\n"   // 2a
                         + "  @BooleanProperty boolean[] arrUnboxBoolean;\n" // 2b
                         + "  @BytesProperty byte[][] arrUnboxByteArr;\n"  // 2b
-                        + "  @BytesProperty Byte[] boxByteArr;\n"         // 2a
                         + "  @StringProperty String[] arrString;\n"        // 2b
                         + "  @DocumentProperty Gift[] arrGift;\n"            // 2c
                         + "\n"
@@ -1259,8 +1259,8 @@
                         + "}\n");
 
         assertThat(compilation).hadErrorContaining(
-                "Property Annotation androidx.appsearch.annotation.Document.BooleanProperty "
-                        + "doesn't accept the data type of property field arrString");
+                "@BooleanProperty must only be placed on a getter/field of type or array or "
+                        + "collection of boolean|java.lang.Boolean");
     }
 
     @Test
@@ -1271,10 +1271,11 @@
                         + "public class Gift {\n"
                         + "  @Namespace String namespace;\n"
                         + "  @Id String id;\n"
-                        + "  @BytesProperty Collection<Byte[]> collectBoxByteArr;\n" // 1x
+                        + "  @BytesProperty Collection<Byte[]> collectBoxByteArr;\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Unhandled out property type (1x): java.util.Collection<java.lang.Byte[]>");
+                "@BytesProperty must only be placed on a getter/field of type or array or "
+                        + "collection of byte[]");
 
         compilation = compile(
                 "import java.util.*;\n"
@@ -1282,10 +1283,11 @@
                         + "public class Gift {\n"
                         + "  @Namespace String namespace;\n"
                         + "  @Id String id;\n"
-                        + "  @BytesProperty Collection<Byte> collectByte;\n" // 1x
+                        + "  @BytesProperty Collection<Byte> collectByte;\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Unhandled out property type (1x): java.util.Collection<java.lang.Byte>");
+                "@BytesProperty must only be placed on a getter/field of type or array or "
+                        + "collection of byte[]");
 
         compilation = compile(
                 "import java.util.*;\n"
@@ -1293,10 +1295,11 @@
                         + "public class Gift {\n"
                         + "  @Namespace String namespace;\n"
                         + "  @Id String id;\n"
-                        + "  @BytesProperty Byte[][] arrBoxByteArr;\n" // 2x
+                        + "  @BytesProperty Byte[][] arrBoxByteArr;\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Unhandled out property type (2x): java.lang.Byte[][]");
+                "@BytesProperty must only be placed on a getter/field of type or array or "
+                        + "collection of byte[]");
     }
 
     @Test
@@ -1614,7 +1617,8 @@
                         + "note2;\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Cannot override a property with a different name");
+                "Property name within the annotation must stay consistent when "
+                        + "overriding annotated members but changed from 'note2' -> 'note2_new'");
 
         // Overridden properties cannot change the types.
         compilation = compile(
@@ -1637,7 +1641,8 @@
                         + "  @LongProperty Long note2;\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Cannot override a property with a different type");
+                "Property type must stay consistent when overriding annotated "
+                        + "members but changed from @StringProperty -> @LongProperty");
     }
 
     @Test
@@ -1953,18 +1958,10 @@
                         + "  @Document.LongProperty(name=\"price2\")\n"
                         + "  public int price;\n"
                         + "}\n");
-        assertThat(compilation).succeededWithoutWarnings();
-        checkResultContains("Gift.java",
-                "new AppSearchSchema.LongPropertyConfig.Builder(\"price1\")");
-        checkResultContains("Gift.java",
-                "new AppSearchSchema.LongPropertyConfig.Builder(\"price2\")");
-        checkResultContains("Gift.java", "document.setPrice(getPriceConv)");
-        checkResultContains("Gift.java", "document.price = priceConv");
-        checkResultContains("Gift.java",
-                "builder.setPropertyLong(\"price1\", document.getPrice())");
-        checkResultContains("Gift.java",
-                "builder.setPropertyLong(\"price2\", document.price)");
-        checkEqualsGolden("Gift.java");
+        assertThat(compilation).hadErrorContaining(
+                "Normalized name \"price\" is already taken up by pre-existing "
+                        + "int Gift#getPrice(). "
+                        + "Please rename this getter/field to something else.");
     }
 
     @Test
@@ -1987,50 +1984,6 @@
     }
 
     @Test
-    public void testSameNameGetterAndFieldAnnotatingBothWithDifferentType() throws Exception {
-        Compilation compilation = compile(
-                "@Document\n"
-                        + "public class Gift {\n"
-                        + "  @Document.Namespace String namespace;\n"
-                        + "  @Document.Id String id;\n"
-                        + "  @Document.DoubleProperty(name=\"price1\")\n"
-                        + "  public double getPrice() { return 0.2; }\n"
-                        + "  public void setPrice(double price) {}\n"
-                        + "  @Document.LongProperty(name=\"price2\")\n"
-                        + "  public int price;\n"
-                        + "}\n");
-        assertThat(compilation).succeededWithoutWarnings();
-        checkResultContains("Gift.java",
-                "new AppSearchSchema.DoublePropertyConfig.Builder(\"price1\")");
-        checkResultContains("Gift.java",
-                "new AppSearchSchema.LongPropertyConfig.Builder(\"price2\")");
-        checkResultContains("Gift.java", "document.setPrice(getPriceConv)");
-        checkResultContains("Gift.java", "document.price = priceConv");
-        checkResultContains("Gift.java",
-                "builder.setPropertyDouble(\"price1\", document.getPrice())");
-        checkResultContains("Gift.java",
-                "builder.setPropertyLong(\"price2\", document.price)");
-        checkEqualsGolden("Gift.java");
-    }
-
-    @Test
-    public void testSameNameGetterAndFieldAnnotatingBothWithoutSetter() throws Exception {
-        Compilation compilation = compile(
-                "@Document\n"
-                        + "public class Gift {\n"
-                        + "  @Document.Namespace String namespace;\n"
-                        + "  @Document.Id String id;\n"
-                        + "  @Document.LongProperty(name=\"price1\")\n"
-                        + "  public int getPrice() { return 0; }\n"
-                        + "  @Document.LongProperty(name=\"price2\")\n"
-                        + "  public int price;\n"
-                        + "}\n");
-        // Cannot find a setter method for the price1 field.
-        assertThat(compilation).hadErrorContaining(
-                "Failed to find any suitable creation methods");
-    }
-
-    @Test
     public void testNameNormalization() throws Exception {
         // getMPrice should correspond to a field named "mPrice"
         // mPrice should correspond to a field named "price"
@@ -2145,11 +2098,12 @@
                         + "  @Document.Namespace String namespace;\n"
                         + "  @Document.Id String id;\n"
                         + "  @Document.StringProperty\n"
-                        + "  private int getPrice() { return 0; }\n"
+                        + "  public int getPrice() { return 0; }\n"
                         + "  public void setPrice(int price) {}\n"
                         + "}\n");
         assertThat(compilation).hadErrorContaining(
-                "Document.StringProperty doesn't accept the data type of property field getPrice");
+                "@StringProperty must only be placed on a getter/field of type or array or "
+                        + "collection of java.lang.String");
     }
 
     public void testCyclicalSchema() throws Exception {
diff --git a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBoth.JAVA b/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBoth.JAVA
deleted file mode 100644
index f00cbad..0000000
--- a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBoth.JAVA
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.example.appsearch;
-
-import androidx.appsearch.app.AppSearchSchema;
-import androidx.appsearch.app.DocumentClassFactory;
-import androidx.appsearch.app.GenericDocument;
-import androidx.appsearch.exceptions.AppSearchException;
-import java.lang.Class;
-import java.lang.Override;
-import java.lang.String;
-import java.util.Collections;
-import java.util.List;
-import javax.annotation.processing.Generated;
-
-@Generated("androidx.appsearch.compiler.AppSearchCompiler")
-public final class $$__AppSearch__Gift implements DocumentClassFactory<Gift> {
-  public static final String SCHEMA_NAME = "Gift";
-
-  @Override
-  public String getSchemaName() {
-    return SCHEMA_NAME;
-  }
-
-  @Override
-  public AppSearchSchema getSchema() throws AppSearchException {
-    return new AppSearchSchema.Builder(SCHEMA_NAME)
-          .addProperty(new AppSearchSchema.LongPropertyConfig.Builder("price1")
-            .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL)
-            .setIndexingType(AppSearchSchema.LongPropertyConfig.INDEXING_TYPE_NONE)
-            .build())
-          .addProperty(new AppSearchSchema.LongPropertyConfig.Builder("price2")
-            .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL)
-            .setIndexingType(AppSearchSchema.LongPropertyConfig.INDEXING_TYPE_NONE)
-            .build())
-          .build();
-  }
-
-  @Override
-  public List<Class<?>> getDependencyDocumentClasses() throws AppSearchException {
-    return Collections.emptyList();
-  }
-
-  @Override
-  public GenericDocument toGenericDocument(Gift document) throws AppSearchException {
-    GenericDocument.Builder<?> builder =
-        new GenericDocument.Builder<>(document.namespace, document.id, SCHEMA_NAME);
-    builder.setPropertyLong("price1", document.getPrice());
-    builder.setPropertyLong("price2", document.price);
-    return builder.build();
-  }
-
-  @Override
-  public Gift fromGenericDocument(GenericDocument genericDoc) throws AppSearchException {
-    String idConv = genericDoc.getId();
-    String namespaceConv = genericDoc.getNamespace();
-    int getPriceConv = (int) genericDoc.getPropertyLong("price1");
-    int priceConv = (int) genericDoc.getPropertyLong("price2");
-    Gift document = new Gift();
-    document.namespace = namespaceConv;
-    document.id = idConv;
-    document.setPrice(getPriceConv);
-    document.price = priceConv;
-    return document;
-  }
-}
diff --git a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBothWithDifferentType.JAVA b/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBothWithDifferentType.JAVA
deleted file mode 100644
index c859b06..0000000
--- a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testSameNameGetterAndFieldAnnotatingBothWithDifferentType.JAVA
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.example.appsearch;
-
-import androidx.appsearch.app.AppSearchSchema;
-import androidx.appsearch.app.DocumentClassFactory;
-import androidx.appsearch.app.GenericDocument;
-import androidx.appsearch.exceptions.AppSearchException;
-import java.lang.Class;
-import java.lang.Override;
-import java.lang.String;
-import java.util.Collections;
-import java.util.List;
-import javax.annotation.processing.Generated;
-
-@Generated("androidx.appsearch.compiler.AppSearchCompiler")
-public final class $$__AppSearch__Gift implements DocumentClassFactory<Gift> {
-  public static final String SCHEMA_NAME = "Gift";
-
-  @Override
-  public String getSchemaName() {
-    return SCHEMA_NAME;
-  }
-
-  @Override
-  public AppSearchSchema getSchema() throws AppSearchException {
-    return new AppSearchSchema.Builder(SCHEMA_NAME)
-          .addProperty(new AppSearchSchema.DoublePropertyConfig.Builder("price1")
-            .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL)
-            .build())
-          .addProperty(new AppSearchSchema.LongPropertyConfig.Builder("price2")
-            .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL)
-            .setIndexingType(AppSearchSchema.LongPropertyConfig.INDEXING_TYPE_NONE)
-            .build())
-          .build();
-  }
-
-  @Override
-  public List<Class<?>> getDependencyDocumentClasses() throws AppSearchException {
-    return Collections.emptyList();
-  }
-
-  @Override
-  public GenericDocument toGenericDocument(Gift document) throws AppSearchException {
-    GenericDocument.Builder<?> builder =
-        new GenericDocument.Builder<>(document.namespace, document.id, SCHEMA_NAME);
-    builder.setPropertyDouble("price1", document.getPrice());
-    builder.setPropertyLong("price2", document.price);
-    return builder.build();
-  }
-
-  @Override
-  public Gift fromGenericDocument(GenericDocument genericDoc) throws AppSearchException {
-    String idConv = genericDoc.getId();
-    String namespaceConv = genericDoc.getNamespace();
-    double getPriceConv = genericDoc.getPropertyDouble("price1");
-    int priceConv = (int) genericDoc.getPropertyLong("price2");
-    Gift document = new Gift();
-    document.namespace = namespaceConv;
-    document.id = idConv;
-    document.setPrice(getPriceConv);
-    document.price = priceConv;
-    return document;
-  }
-}
diff --git a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testToGenericDocument_allSupportedTypes.JAVA b/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testToGenericDocument_allSupportedTypes.JAVA
index 1f34209..ec1e5ac 100644
--- a/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testToGenericDocument_allSupportedTypes.JAVA
+++ b/appsearch/compiler/src/test/resources/androidx/appsearch/compiler/goldens/testToGenericDocument_allSupportedTypes.JAVA
@@ -5,7 +5,6 @@
 import androidx.appsearch.app.GenericDocument;
 import androidx.appsearch.exceptions.AppSearchException;
 import java.lang.Boolean;
-import java.lang.Byte;
 import java.lang.Class;
 import java.lang.Double;
 import java.lang.Float;
@@ -98,9 +97,6 @@
           .addProperty(new AppSearchSchema.BytesPropertyConfig.Builder("arrUnboxByteArr")
             .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_REPEATED)
             .build())
-          .addProperty(new AppSearchSchema.BytesPropertyConfig.Builder("boxByteArr")
-            .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL)
-            .build())
           .addProperty(new AppSearchSchema.StringPropertyConfig.Builder("arrString")
             .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_REPEATED)
             .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_NONE)
@@ -312,14 +308,6 @@
     if (arrUnboxByteArrCopy != null) {
       builder.setPropertyBytes("arrUnboxByteArr", arrUnboxByteArrCopy);
     }
-    Byte[] boxByteArrCopy = document.boxByteArr;
-    if (boxByteArrCopy != null) {
-      byte[] boxByteArrConv = new byte[boxByteArrCopy.length];
-      for (int i = 0 ; i < boxByteArrCopy.length ; i++) {
-        boxByteArrConv[i] = boxByteArrCopy[i];
-      }
-      builder.setPropertyBytes("boxByteArr", boxByteArrConv);
-    }
     String[] arrStringCopy = document.arrString;
     if (arrStringCopy != null) {
       builder.setPropertyString("arrString", arrStringCopy);
@@ -498,14 +486,6 @@
     }
     boolean[] arrUnboxBooleanConv = genericDoc.getPropertyBooleanArray("arrUnboxBoolean");
     byte[][] arrUnboxByteArrConv = genericDoc.getPropertyBytesArray("arrUnboxByteArr");
-    byte[] boxByteArrCopy = genericDoc.getPropertyBytes("boxByteArr");
-    Byte[] boxByteArrConv = null;
-    if (boxByteArrCopy != null) {
-      boxByteArrConv = new Byte[boxByteArrCopy.length];
-      for (int i = 0; i < boxByteArrCopy.length; i++) {
-        boxByteArrConv[i] = boxByteArrCopy[i];
-      }
-    }
     String[] arrStringConv = genericDoc.getPropertyStringArray("arrString");
     GenericDocument[] arrGiftCopy = genericDoc.getPropertyDocumentArray("arrGift");
     Gift[] arrGiftConv = null;
@@ -582,7 +562,6 @@
     document.arrBoxBoolean = arrBoxBooleanConv;
     document.arrUnboxBoolean = arrUnboxBooleanConv;
     document.arrUnboxByteArr = arrUnboxByteArrConv;
-    document.boxByteArr = boxByteArrConv;
     document.arrString = arrStringConv;
     document.arrGift = arrGiftConv;
     document.string = stringConv;
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/buildInfo/CreateLibraryBuildInfoFileTask.kt b/buildSrc/private/src/main/kotlin/androidx/build/buildInfo/CreateLibraryBuildInfoFileTask.kt
index 5719da9..fb6620e 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/buildInfo/CreateLibraryBuildInfoFileTask.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/buildInfo/CreateLibraryBuildInfoFileTask.kt
@@ -235,7 +235,8 @@
                 .sortedWith(compareBy({ it.groupId }, { it.artifactId }, { it.version }))
 
         private fun String?.isAndroidXDependency() =
-            this != null && startsWith("androidx.") && !startsWith("androidx.test")
+            this != null && startsWith("androidx.") && !startsWith("androidx.test") &&
+                !startsWith("androidx.databinding")
 
         /* For androidx release notes, the most common use case is to track and publish the last sha
          * of the build that is released.  Thus, we use frameworks/support to get the sha
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 724504f..432cb50 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
@@ -572,55 +572,12 @@
                     ":benchmark:benchmark-macro",
                     ":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"
-                ),
                 // Changing generator code changes the output for generated icons, which are tested
-                // in
-                // material-icons-extended.
+                // in material-icons-extended.
                 setOf(
                     ":compose:material:material:icons:generator",
                     ":compose:material:material-icons-extended"
                 ),
-                // 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"
-                ),
                 setOf(
                     ":profileinstaller:integration-tests:profile-verification",
                     ":profileinstaller:integration-tests:profile-verification-sample",
@@ -628,10 +585,6 @@
                         "profile-verification-sample-no-initializer",
                     ":benchmark:integration-tests:baselineprofile-consumer",
                 ),
-                setOf(
-                    ":window:integration-tests:macrobenchmark",
-                    ":window:integration-tests:macrobenchmark-target",
-                )
             )
 
         val IGNORED_PATHS =
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt b/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
index 5836ed8..669aa02 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/sbom/Sbom.kt
@@ -21,6 +21,7 @@
 import androidx.build.GMavenZipTask
 import androidx.build.ProjectLayoutType
 import androidx.build.addToBuildOnServer
+import androidx.build.getDistributionDirectory
 import androidx.build.getPrebuiltsRoot
 import androidx.build.getSupportRootFolder
 import androidx.build.gitclient.MultiGitClient
@@ -34,6 +35,7 @@
 import org.gradle.api.Project
 import org.gradle.api.artifacts.Configuration
 import org.gradle.api.artifacts.ModuleVersionIdentifier
+import org.gradle.api.tasks.Copy
 import org.gradle.api.tasks.bundling.AbstractArchiveTask
 import org.gradle.api.tasks.bundling.Zip
 import org.gradle.jvm.tasks.Jar
@@ -201,6 +203,8 @@
 /** Enables the publishing of an sbom that lists our embedded dependencies */
 fun Project.configureSbomPublishing() {
     val uuid = project.coordinatesToUUID().toString()
+    val projectName = project.name
+    val projectVersion = project.version.toString()
 
     project.configurations.create(sbomEmptyConfiguration)
     project.apply(plugin = "org.spdx.sbom")
@@ -209,9 +213,30 @@
     val supportRootDir = getSupportRootFolder()
 
     val allowPublicRepos = System.getenv("ALLOW_PUBLIC_REPOS") != null
+    val sbomPublishDir = project.getSbomPublishDir()
+
+    val sbomBuiltFile = project.layout.buildDirectory.file(
+        "spdx/release.spdx.json"
+    ).get().getAsFile()
+
+    val publishTask = project.tasks.register("exportSboms", Copy::class.java) { publishTask ->
+        publishTask.destinationDir = sbomPublishDir
+        val sbomBuildDir = sbomBuiltFile.parentFile
+        publishTask.from(sbomBuildDir)
+        publishTask.rename(sbomBuiltFile.name, "$projectName-$projectVersion.spdx.json")
+
+        publishTask.doFirst {
+            if (!sbomBuiltFile.exists()) {
+                throw GradleException(
+                    "sbom file does not exist: $sbomBuiltFile"
+                )
+            }
+        }
+    }
 
     project.tasks.withType(SpdxSbomTask::class.java).configureEach { task ->
         val sbomProjectDir = project.projectDir
+
         task.taskExtension.set(
             object : DefaultSpdxSbomTaskExtension() {
                 override fun mapRepoUri(repoUri: URI, artifact: ModuleVersionIdentifier): URI {
@@ -277,6 +302,9 @@
             target.getConfigurations().set(sbomConfigurations)
         }
         project.addToBuildOnServer(tasks.named("spdxSbomForRelease"))
+        publishTask.configure { task ->
+            task.dependsOn("spdxSbomForRelease")
+        }
     }
 }
 
@@ -310,6 +338,11 @@
     throw GradleException("Could not identify git remote url for project at $dir")
 }
 
+fun Project.getSbomPublishDir(): File {
+    val groupPath = project.group.toString().replace(".", "/")
+    return File(getDistributionDirectory(), "sboms/$groupPath/${project.name}/${project.version}")
+}
+
 private const val MAVEN_CENTRAL_REPO_URL = "https://repo.maven.apache.org/maven2"
 private const val GMAVEN_REPO_URL = "https://dl.google.com/android/maven2"
 /** Returns a mapping from local repo url to public repo url */
diff --git a/busytown/androidx-studio-integration-vitals.sh b/busytown/androidx-studio-integration-vitals.sh
index f96ca2f..be6112ff 100755
--- a/busytown/androidx-studio-integration-vitals.sh
+++ b/busytown/androidx-studio-integration-vitals.sh
@@ -4,6 +4,7 @@
 "$SCRIPT_PATH"/impl/build-studio-and-androidx.sh \
     -Pandroidx.summarizeStderr \
     tasks \
+    listTaskOutputs \
     :navigation:navigation-safe-args-gradle-plugin:test \
     --stacktrace \
     --no-daemon
diff --git a/busytown/androidx.sh b/busytown/androidx.sh
index cbecee6..1adf6b1 100755
--- a/busytown/androidx.sh
+++ b/busytown/androidx.sh
@@ -19,7 +19,7 @@
 else
   # Run Gradle
   # If/when we enable desktop, enable VerifyDependencyVersionsTask.kt/shouldVerifyConfiguration
-  if ! impl/build.sh buildOnServer createAllArchives checkExternalLicenses listTaskOutputs \
+  if ! impl/build.sh buildOnServer createAllArchives checkExternalLicenses listTaskOutputs exportSboms \
       -Pandroidx.enableComposeCompilerMetrics=true \
       -Pandroidx.enableComposeCompilerReports=true \
       -Pandroidx.constraints=true \
diff --git a/busytown/androidx_compose_multiplatform.sh b/busytown/androidx_compose_multiplatform.sh
index d14184c..cd3cca9 100755
--- a/busytown/androidx_compose_multiplatform.sh
+++ b/busytown/androidx_compose_multiplatform.sh
@@ -13,7 +13,6 @@
       -Pandroidx.enableComposeCompilerMetrics=true \
       -Pandroidx.enableComposeCompilerReports=true \
       -Pandroidx.constraints=true \
-      --no-daemon \
       --profile \
       compileDebugAndroidTestSources \
       compileDebugSources \
diff --git a/busytown/androidx_incremental.sh b/busytown/androidx_incremental.sh
index f8ed79c..0c0a486 100755
--- a/busytown/androidx_incremental.sh
+++ b/busytown/androidx_incremental.sh
@@ -64,7 +64,7 @@
 else
     # Run Gradle
     # TODO: when b/278730831 ( https://youtrack.jetbrains.com/issue/KT-58547 ) is resolved, remove "-Pkotlin.incremental=false"
-    if impl/build.sh $DIAGNOSE_ARG buildOnServer checkExternalLicenses listTaskOutputs \
+    if impl/build.sh $DIAGNOSE_ARG buildOnServer checkExternalLicenses listTaskOutputs exportSboms \
         --profile \
         -Pkotlin.incremental=false \
         "$@"; then
diff --git a/busytown/impl/build.sh b/busytown/impl/build.sh
index 3b10921..ebf526a 100755
--- a/busytown/impl/build.sh
+++ b/busytown/impl/build.sh
@@ -126,7 +126,11 @@
       # We probably won't have enough time to fully diagnose the problem given this timeout, but
       # we might be able to determine whether this problem is reproducible enough for a developer to
       # more easily investigate further
-      ./development/diagnose-build-failure/diagnose-build-failure.sh $DIAGNOSE_TIMEOUT_ARG "--ci $*"
+      ./development/diagnose-build-failure/diagnose-build-failure.sh $DIAGNOSE_TIMEOUT_ARG "--ci $*" || true
+      scansPrevDir="$DIST_DIR/scans-prev"
+      mkdir -p "$scansPrevDir"
+      # restore any prior build scans into the dist dir
+      cp ../../diagnose-build-failure/prev/dist/scan*.zip "$scansPrevDir/" || true
     fi
   fi
   BUILD_STATUS=1 # failure
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
index cec0faa..3de98be 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/JpegBytes2Disk.java
@@ -24,7 +24,6 @@
 import android.net.Uri;
 import android.os.Build;
 import android.provider.MediaStore;
-import android.util.Range;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -112,15 +111,7 @@
             @NonNull File tempFile, @NonNull byte[] bytes) throws ImageCaptureException {
         try (FileOutputStream output = new FileOutputStream(tempFile)) {
             InvalidJpegDataParser invalidJpegDataParser = new InvalidJpegDataParser();
-            Range<Integer> invalidDataRange = invalidJpegDataParser.getInvalidDataRange(bytes);
-
-            if (invalidDataRange != null) {
-                output.write(bytes, 0, invalidDataRange.getLower());
-                output.write(bytes, invalidDataRange.getUpper() + 1,
-                        (bytes.length - invalidDataRange.getUpper() - 1));
-            } else {
-                output.write(bytes);
-            }
+            output.write(bytes, 0, invalidJpegDataParser.getValidDataLength(bytes));
         } catch (IOException e) {
             throw new ImageCaptureException(ERROR_FILE_IO, "Failed to write to temp file", e);
         }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/LargeJpegImageQuirk.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/LargeJpegImageQuirk.java
index e73a437..bb050d4 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/LargeJpegImageQuirk.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/quirk/LargeJpegImageQuirk.java
@@ -32,7 +32,7 @@
  *     Description: Quirk required to check whether the captured JPEG image contains redundant
  *                  0's padding data. For example, Samsung A5 (2017) series devices have the
  *                  problem and result in the output JPEG image to be extremely large (about 32 MB).
- *     Device(s): Samsung Galaxy A5 (2017) series
+ *     Device(s): Samsung Galaxy A5 (2017), A52, A70 and A72 series devices
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public final class LargeJpegImageQuirk implements Quirk {
@@ -40,11 +40,26 @@
     private static final Set<String> DEVICE_MODELS = new HashSet<>(Arrays.asList(
             // Samsung Galaxy A5 series devices
             "SM-A520F",
+            "SM-A520L",
+            "SM-A520K",
+            "SM-A520S",
             "SM-A520X",
             "SM-A520W",
-            "SM-A520K",
-            "SM-A520L",
-            "SM-A520S"
+            // Samsung Galaxy A52 series devices
+            "SM-A525F",
+            "SM-A525M",
+            // Samsung Galaxy A70 series devices
+            "SM-A705F",
+            "SM-A705FN",
+            "SM-A705GM",
+            "SM-A705MN",
+            "SM-A7050",
+            "SM-A705W",
+            "SM-A705YN",
+            "SM-A705U",
+            // Samsung Galaxy A72 series devices
+            "SM-A725F",
+            "SM-A725M"
     ));
 
     static boolean load() {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParser.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParser.java
index 4f3bdbb..a9b122a 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParser.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParser.java
@@ -16,10 +16,7 @@
 
 package androidx.camera.core.internal.compat.workaround;
 
-import android.util.Range;
-
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.camera.core.internal.compat.quirk.DeviceQuirks;
 import androidx.camera.core.internal.compat.quirk.LargeJpegImageQuirk;
@@ -34,15 +31,14 @@
     private final boolean mHasQuirk = DeviceQuirks.get(LargeJpegImageQuirk.class) != null;
 
     /**
-     * Retrieves the invalid data position range from the input JPEG byte data array.
+     * Returns the valid data length of the input JPEG byte data array which is determined by the
+     * JFIF EOI byte.
      *
-     * @return the invalid data position range of the JPEG byte data, or {@code null} when
-     * invalid data position range can't be found.
+     * <p>Returns the original byte array length when quirk doesn't exist or EOI can't be found.
      */
-    @Nullable
-    public Range<Integer> getInvalidDataRange(@NonNull byte[] bytes) {
+    public int getValidDataLength(@NonNull byte[] bytes) {
         if (!mHasQuirk) {
-            return null;
+            return bytes.length;
         }
 
         // Parses the JFIF segments from the start of the JPEG image data
@@ -50,7 +46,7 @@
         while (true) {
             // Breaks the while-loop and return null if the mark byte can't be correctly found.
             if (markPosition + 4 > bytes.length || bytes[markPosition] != ((byte) 0xff)) {
-                return null;
+                return bytes.length;
             }
 
             int segmentLength =
@@ -69,7 +65,7 @@
         while (true) {
             // Breaks the while-loop and return null if EOI mark can't be found
             if (eoiPosition + 2 > bytes.length) {
-                return null;
+                return bytes.length;
             }
 
             if (bytes[eoiPosition] == ((byte) 0xff) && bytes[eoiPosition + 1] == ((byte) 0xd9)) {
@@ -78,27 +74,6 @@
             eoiPosition++;
         }
 
-        // The captured images might have non-zero data after the EOI byte. Those valid data should
-        // be kept. Searches the final valid byte from the end side can save the processing time.
-        int finalValidBytePosition = bytes.length - 1;
-
-        while (true) {
-            // Breaks the while-loop and return null if finalValidBytePosition has reach the EOI
-            // mark position.
-            if (finalValidBytePosition <= eoiPosition) {
-                return null;
-            }
-
-            if (bytes[finalValidBytePosition] == ((byte) 0xff)) {
-                break;
-            }
-            finalValidBytePosition--;
-        }
-
-        if (finalValidBytePosition - 1 > eoiPosition + 2) {
-            return Range.create(eoiPosition + 2, finalValidBytePosition - 1);
-        } else {
-            return null;
-        }
+        return eoiPosition + 2;
     }
 }
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParserTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParserTest.kt
index 3623de8..cdd2576 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParserTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/internal/compat/workaround/InvalidJpegDataParserTest.kt
@@ -17,9 +17,7 @@
 package androidx.camera.core.internal.compat.workaround
 
 import android.os.Build
-import android.util.Range
 import com.google.common.truth.Truth.assertThat
-import java.util.Objects
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.ParameterizedRobolectricTestRunner
@@ -39,13 +37,21 @@
     0xff, 0xd9
 ).map { it.toByte() }.toByteArray()
 
-// Invalid data starts from position 18 to position 31.
+// Problematic data with one segment of redundant 0 padding data.
 private val problematicJpegByteArray = listOf(
     0xff, 0xd8, 0xff, 0xe1, 0x00, 0x06, 0x55, 0x55, 0x55, 0x55, 0xff, 0xda, 0x99, 0x99, 0x99, 0x99,
     0xff, 0xd9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
     0xff, 0x00, 0x00, 0xe5, 0x92, 0x00, 0x00, 0xe6, 0x01, 0x00
 ).map { it.toByte() }.toByteArray()
 
+// Problematic data with two segments of redundant 0 padding data.
+private val problematicJpegByteArray2 = listOf(
+    0xff, 0xd8, 0xff, 0xe1, 0x00, 0x06, 0x55, 0x55, 0x55, 0x55, 0xff, 0xda, 0x99, 0x99, 0x99, 0x99,
+    0xff, 0xd9, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0xff, 0x00, 0x00, 0xe5, 0x92, 0x00, 0x00, 0xe6, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+).map { it.toByte() }.toByteArray()
+
 // Invalid very short data
 private val invalidVeryShortData = listOf(
     0xff, 0xd8
@@ -69,33 +75,30 @@
 class InvalidJpegDataParserTest(
     private val model: String,
     private val data: ByteArray,
-    private val range: Range<*>?,
+    private val validDataLength: Int,
     ) {
 
     companion object {
         @JvmStatic
-        @ParameterizedRobolectricTestRunner.Parameters(name = "model={0}, data={1}, range={2}")
+        @ParameterizedRobolectricTestRunner.Parameters(name = "model={0}, data={1}, length={2}")
         fun data() = mutableListOf<Array<Any?>>().apply {
-            add(arrayOf("SM-A520F", problematicJpegByteArray, Range.create(18, 31)))
-            add(arrayOf("SM-A520F", correctJpegByteArray1, null))
-            add(arrayOf("SM-A520F", correctJpegByteArray2, null))
-            add(arrayOf("SM-A520F", invalidVeryShortData, null))
-            add(arrayOf("SM-A520F", invalidNoSosData, null))
-            add(arrayOf("SM-A520F", invalidNoEoiData, null))
-            add(arrayOf("fake-model", problematicJpegByteArray, null))
-            add(arrayOf("fake-model", correctJpegByteArray1, null))
-            add(arrayOf("fake-model", correctJpegByteArray2, null))
+            add(arrayOf("SM-A520F", problematicJpegByteArray, 18))
+            add(arrayOf("SM-A520F", problematicJpegByteArray2, 18))
+            add(arrayOf("SM-A520F", correctJpegByteArray1, 18))
+            add(arrayOf("SM-A520F", correctJpegByteArray2, 18))
+            add(arrayOf("SM-A520F", invalidVeryShortData, 2))
+            add(arrayOf("SM-A520F", invalidNoSosData, 28))
+            add(arrayOf("SM-A520F", invalidNoEoiData, 28))
+            add(arrayOf("fake-model", problematicJpegByteArray, 42))
+            add(arrayOf("fake-model", problematicJpegByteArray2, 64))
+            add(arrayOf("fake-model", correctJpegByteArray1, 28))
+            add(arrayOf("fake-model", correctJpegByteArray2, 18))
         }
     }
 
     @Test
-    fun canGetInvalidJpegDataRange() {
+    fun canGetValidJpegDataLength() {
         ReflectionHelpers.setStaticField(Build::class.java, "MODEL", model)
-        assertThat(
-            Objects.equals(
-                InvalidJpegDataParser().getInvalidDataRange(data),
-                range
-            )
-        ).isTrue()
+        assertThat(InvalidJpegDataParser().getValidDataLength(data)).isEqualTo(validDataLength)
     }
 }
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/internal/encoder/VideoEncoderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/internal/encoder/VideoEncoderTest.kt
index 3865d61..801b947 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/internal/encoder/VideoEncoderTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/internal/encoder/VideoEncoderTest.kt
@@ -362,7 +362,8 @@
         verify(videoEncoderCallback, timeout(5000L)).onEncodeStop()
 
         // If the last data timestamp is null, it means the encoding is probably stopped because of timeout.
-        assertThat(videoEncoder.mLastDataStopTimestamp).isNotNull()
+        // Skip null since it could be a device performance issue which is out of the test scope.
+        assumeTrue(videoEncoder.mLastDataStopTimestamp != null)
         assertThat(videoEncoder.mLastDataStopTimestamp).isAtLeast(stopTimeUs)
     }
 
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
index 1e6288a..bc2f76f0 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
@@ -2869,8 +2869,13 @@
                                 // Toggle on pending status for the video file.
                                 contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
                             }
-                            outputUri = mediaStoreOutputOptions.getContentResolver().insert(
-                                    mediaStoreOutputOptions.getCollectionUri(), contentValues);
+                            try {
+                                outputUri = mediaStoreOutputOptions.getContentResolver().insert(
+                                        mediaStoreOutputOptions.getCollectionUri(), contentValues);
+                            } catch (RuntimeException e) {
+                                throw new IOException("Unable to create MediaStore entry by " + e,
+                                        e);
+                            }
                             if (outputUri == null) {
                                 throw new IOException("Unable to create MediaStore entry.");
                             }
@@ -3092,7 +3097,12 @@
                 throw new AssertionError("One-time media muxer creation has already occurred for"
                         + " recording " + this);
             }
-            return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
+
+            try {
+                return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
+            } catch (RuntimeException e) {
+                throw new IOException("Failed to create MediaMuxer by " + e, e);
+            }
         }
 
         /**
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/PendingValue.java b/camera/camera-view/src/main/java/androidx/camera/view/PendingValue.java
index 5b5d1fd..1b12c9e 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/PendingValue.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/PendingValue.java
@@ -17,7 +17,6 @@
 package androidx.camera.view;
 
 import static androidx.camera.core.impl.utils.Threads.checkMainThread;
-import static androidx.core.util.Preconditions.checkState;
 
 import static java.util.Objects.requireNonNull;
 
@@ -31,6 +30,7 @@
 import androidx.camera.core.CameraControl;
 import androidx.camera.core.impl.utils.futures.Futures;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
+import androidx.core.util.Pair;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
@@ -46,11 +46,7 @@
 class PendingValue<T> {
 
     @Nullable
-    private T mValue;
-    @Nullable
-    private ListenableFuture<Void> mListenableFuture;
-    @Nullable
-    private CallbackToFutureAdapter.Completer<Void> mCompleter;
+    private Pair<CallbackToFutureAdapter.Completer<Void>, T> mCompleterAndValue;
 
     /**
      * Assigns the pending value.
@@ -60,19 +56,14 @@
     @MainThread
     ListenableFuture<Void> setValue(@NonNull T value) {
         checkMainThread();
-        if (mListenableFuture != null) {
-            checkState(!mListenableFuture.isDone(),
-                    "#setValue() is called after the value is propagated.");
-            // Cancel the previous ListenableFuture.
-            mListenableFuture.cancel(false);
-        }
-        // Track the pending value and the ListenableFuture.
-        mValue = value;
-        mListenableFuture = CallbackToFutureAdapter.getFuture(completer -> {
-            mCompleter = completer;
+        return CallbackToFutureAdapter.getFuture(completer -> {
+            // Track the pending value and the completer.
+            if (mCompleterAndValue != null) {
+                requireNonNull(mCompleterAndValue.first).setCancelled();
+            }
+            mCompleterAndValue = new Pair<>(completer, value);
             return "PendingValue " + value;
         });
-        return mListenableFuture;
     }
 
     /**
@@ -83,8 +74,10 @@
     @MainThread
     void propagateIfHasValue(Function<T, ListenableFuture<Void>> setValueFunction) {
         checkMainThread();
-        if (mValue != null) {
-            Futures.propagate(setValueFunction.apply(mValue), requireNonNull(mCompleter));
+        if (mCompleterAndValue != null) {
+            Futures.propagate(setValueFunction.apply(mCompleterAndValue.second),
+                    requireNonNull(mCompleterAndValue.first));
+            mCompleterAndValue = null;
         }
     }
 }
diff --git a/camera/camera-view/src/test/java/androidx/camera/view/CameraControllerTest.kt b/camera/camera-view/src/test/java/androidx/camera/view/CameraControllerTest.kt
index 2474292..9033f3e 100644
--- a/camera/camera-view/src/test/java/androidx/camera/view/CameraControllerTest.kt
+++ b/camera/camera-view/src/test/java/androidx/camera/view/CameraControllerTest.kt
@@ -152,6 +152,26 @@
     }
 
     @Test
+    fun unbindController_canSetPendingValueAgain() {
+        // Arrange: set pending values
+        var linearZoomFuture = controller.setLinearZoom(LINEAR_ZOOM)
+
+        // Act: complete initialization.
+        completeCameraInitialization()
+        // Assert: pending value is set.
+        assertThat(fakeCameraControl.linearZoom).isEqualTo(LINEAR_ZOOM)
+        assertThat(linearZoomFuture.isDone).isTrue()
+
+        // Act: unbind controller, set pending value again and rebind.
+        controller.unbind()
+        linearZoomFuture = controller.setLinearZoom(1F)
+        controller.bindToLifecycle(FakeLifecycleOwner())
+        // Assert: pending value is set to new value.
+        assertThat(fakeCameraControl.linearZoom).isEqualTo(1F)
+        assertThat(linearZoomFuture.isDone).isTrue()
+    }
+
+    @Test
     fun initCompletes_torchStatePropagated() {
         // Arrange: get LiveData before init completes
         val torchState = controller.torchState
diff --git a/camera/camera-view/src/test/java/androidx/camera/view/PendingValueTest.kt b/camera/camera-view/src/test/java/androidx/camera/view/PendingValueTest.kt
index 91263d6..1beccf2 100644
--- a/camera/camera-view/src/test/java/androidx/camera/view/PendingValueTest.kt
+++ b/camera/camera-view/src/test/java/androidx/camera/view/PendingValueTest.kt
@@ -34,6 +34,59 @@
 class PendingValueTest {
 
     @Test
+    fun assignPendingValueAfterPropagation_valueAssigned() {
+        // Arrange: create a pending value.
+        val pendingValue = PendingValue<Boolean>()
+        var assignedValue: Boolean? = null
+
+        // Act: set the pending value and propagate the result.
+        val future1 = pendingValue.setValue(true)
+        pendingValue.propagateIfHasValue {
+            assignedValue = it
+            Futures.immediateFuture(null)
+        }
+        // Assert: value is propagated to core. App future is done.
+        assertThat(assignedValue).isTrue()
+        assertThat(future1.isDone).isTrue()
+
+        // Act: set the pending value again.
+        val future2 = pendingValue.setValue(false)
+        pendingValue.propagateIfHasValue {
+            assignedValue = it
+            Futures.immediateFuture(null)
+        }
+        // Assert: value is propagated to core. App future is done.
+        assertThat(assignedValue).isFalse()
+        assertThat(future2.isDone).isTrue()
+    }
+
+    @Test
+    fun propagationTwiceForTheSameAssignment_theSecondTimeIsNoOp() {
+        // Arrange: create a pending value.
+        val pendingValue = PendingValue<Boolean>()
+
+        // Act: set the pending value and propagate the result.
+        val future1 = pendingValue.setValue(true)
+        var assignedValue: Boolean? = null
+        pendingValue.propagateIfHasValue {
+            assignedValue = it
+            Futures.immediateFuture(null)
+        }
+        // Assert: value is propagated to core. App future is done.
+        assertThat(assignedValue).isTrue()
+        assertThat(future1.isDone).isTrue()
+
+        // Act: propagate the result again. e.g. when camera is restarted
+        var assignedValue2: Boolean? = null
+        pendingValue.propagateIfHasValue {
+            assignedValue2 = it
+            Futures.immediateFuture(null)
+        }
+        // Assert: the value set in the previous session will not be propagated.
+        assertThat(assignedValue2).isNull()
+    }
+
+    @Test
     fun assignPendingValueTwice_theSecondValueIsAssigned() {
         // Arrange.
         val pendingValue = PendingValue<Boolean>()
diff --git a/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/StreamSharingFragment.kt b/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/StreamSharingFragment.kt
index 11bc6e6..0e1e36a 100644
--- a/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/StreamSharingFragment.kt
+++ b/camera/integration-tests/viewtestapp/src/main/java/androidx/camera/integration/view/StreamSharingFragment.kt
@@ -22,11 +22,8 @@
 import android.view.LayoutInflater
 import android.view.OrientationEventListener
 import android.view.View
-import android.view.View.GONE
-import android.view.View.VISIBLE
 import android.view.ViewGroup
 import android.widget.Button
-import android.widget.TextView
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraSelector
 import androidx.camera.core.ImageAnalysis
@@ -54,13 +51,13 @@
 private const val PREFIX_INFORMATION = "test_information"
 private const val PREFIX_VIDEO = "video"
 private const val KEY_ORIENTATION = "device_orientation"
+private const val KEY_STREAM_SHARING_STATE = "is_stream_sharing_enabled"
 
 class StreamSharingFragment : Fragment() {
 
     private lateinit var previewView: PreviewView
     private lateinit var exportButton: Button
     private lateinit var recordButton: Button
-    private lateinit var streamSharingStateText: TextView
     private lateinit var useCases: Array<UseCase>
     private var camera: Camera? = null
     private var activeRecording: Recording? = null
@@ -81,7 +78,6 @@
     ): View? {
         val view = inflater.inflate(R.layout.fragment_stream_sharing, container, false)
         previewView = view.findViewById(R.id.preview_view)
-        streamSharingStateText = view.findViewById(R.id.stream_sharing_state)
         exportButton = view.findViewById(R.id.export_button)
         exportButton.setOnClickListener {
             exportTestInformation()
@@ -113,8 +109,8 @@
     }
 
     private fun bindUseCases(cameraProvider: ProcessCameraProvider) {
-        displayStreamSharingState(GONE)
         enableRecording(false)
+        isUseCasesBound = false
         cameraProvider.unbindAll()
         useCases = arrayOf(
             createPreview(),
@@ -130,9 +126,6 @@
             Logger.e(TAG, "Failed to bind use cases.", exception)
             false
         }
-        if (isStreamSharingEnabled()) {
-            displayStreamSharingState(VISIBLE)
-        }
     }
 
     private fun createPreview(): Preview {
@@ -187,10 +180,6 @@
         return !isCombinationSupported && isUseCasesBound
     }
 
-    private fun displayStreamSharingState(visibility: Int) {
-        streamSharingStateText.visibility = visibility
-    }
-
     private fun enableRecording(enabled: Boolean) {
         recordButton.isEnabled = enabled
     }
@@ -227,7 +216,8 @@
 
     private fun exportTestInformation() {
         val fileName = generateFileName(PREFIX_INFORMATION)
-        val information = "$KEY_ORIENTATION: $deviceOrientation"
+        val information = "$KEY_ORIENTATION:$deviceOrientation" +
+            "\n" + "$KEY_STREAM_SHARING_STATE:${isStreamSharingEnabled()}"
 
         writeTextToExternalFile(information, fileName)
     }
diff --git a/camera/integration-tests/viewtestapp/src/main/res/layout/fragment_stream_sharing.xml b/camera/integration-tests/viewtestapp/src/main/res/layout/fragment_stream_sharing.xml
index 36f0664..d97b32a 100644
--- a/camera/integration-tests/viewtestapp/src/main/res/layout/fragment_stream_sharing.xml
+++ b/camera/integration-tests/viewtestapp/src/main/res/layout/fragment_stream_sharing.xml
@@ -54,16 +54,6 @@
                 android:layout_height="wrap_content"
                 android:enabled="false"
                 android:text="@string/btn_video_record" />
-
-            <TextView
-                android:id="@+id/stream_sharing_state"
-                android:layout_width="wrap_content"
-                android:layout_height="match_parent"
-                android:gravity="center"
-                android:padding="8dp"
-                android:text="@string/text_stream_sharing_enabled"
-                android:textAlignment="center"
-                android:visibility="gone" />
         </LinearLayout>
     </LinearLayout>
 </LinearLayout>
\ No newline at end of file
diff --git a/camera/integration-tests/viewtestapp/src/main/res/values/donottranslate-strings.xml b/camera/integration-tests/viewtestapp/src/main/res/values/donottranslate-strings.xml
index 328f967..5819348 100644
--- a/camera/integration-tests/viewtestapp/src/main/res/values/donottranslate-strings.xml
+++ b/camera/integration-tests/viewtestapp/src/main/res/values/donottranslate-strings.xml
@@ -64,6 +64,5 @@
     <string name="mirror_on">mirror on</string>
     <string name="mirror_off">mirror off</string>
     <string name="mlkit">MLKit</string>
-    <string name="text_stream_sharing_enabled">SteamSharing enabled</string>
 
 </resources>
diff --git a/car/app/app-samples/showcase/common/src/main/res/values-pt-rBR/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values-pt-rBR/strings.xml
index 1c6bdb6..d5d524a 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values-pt-rBR/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values-pt-rBR/strings.xml
@@ -342,7 +342,7 @@
     <string name="perm_group" msgid="3834918337351876270">"Grupo de permissões"</string>
     <string name="perm_group_description" msgid="7348847631139139024">"Grupo de permissões do app Showcase"</string>
     <string name="perm_fine_location" msgid="5438874642600304118">"Acesso à localização precisa"</string>
-    <string name="perm_fine_location_desc" msgid="3549183883787912516">"Permissão para acessar a localização precisa"</string>
+    <string name="perm_fine_location_desc" msgid="3549183883787912516">"Permissão para acessar o local exato"</string>
     <string name="perm_coarse_location" msgid="6140337431619481015">"Acesso à localização aproximada"</string>
     <string name="perm_coarse_location_desc" msgid="6074759942301565943">"Permissão para acessar a localização aproximada"</string>
     <string name="perm_record_audio" msgid="2758340693260523493">"Acesso à gravação de áudio"</string>
diff --git a/car/app/app-samples/showcase/common/src/main/res/values-pt/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values-pt/strings.xml
index 1c6bdb6..d5d524a 100644
--- a/car/app/app-samples/showcase/common/src/main/res/values-pt/strings.xml
+++ b/car/app/app-samples/showcase/common/src/main/res/values-pt/strings.xml
@@ -342,7 +342,7 @@
     <string name="perm_group" msgid="3834918337351876270">"Grupo de permissões"</string>
     <string name="perm_group_description" msgid="7348847631139139024">"Grupo de permissões do app Showcase"</string>
     <string name="perm_fine_location" msgid="5438874642600304118">"Acesso à localização precisa"</string>
-    <string name="perm_fine_location_desc" msgid="3549183883787912516">"Permissão para acessar a localização precisa"</string>
+    <string name="perm_fine_location_desc" msgid="3549183883787912516">"Permissão para acessar o local exato"</string>
     <string name="perm_coarse_location" msgid="6140337431619481015">"Acesso à localização aproximada"</string>
     <string name="perm_coarse_location_desc" msgid="6074759942301565943">"Permissão para acessar a localização aproximada"</string>
     <string name="perm_record_audio" msgid="2758340693260523493">"Acesso à gravação de áudio"</string>
diff --git a/collection/collection-benchmark/build.gradle b/collection/collection-benchmark/build.gradle
index 11b20b6..f28fbdd 100644
--- a/collection/collection-benchmark/build.gradle
+++ b/collection/collection-benchmark/build.gradle
@@ -65,12 +65,12 @@
         androidTest {
             dependsOn(commonTest)
             dependencies {
-                implementation("androidx.benchmark:benchmark-junit4:1.1.1")
+                implementation(projectOrArtifact(":benchmark:benchmark-junit4"))
                 implementation(libs.junit)
-                implementation("androidx.test.ext:junit:1.1.5")
-                implementation("androidx.test:core:1.5.0")
-                implementation("androidx.test:runner:1.5.2")
-                implementation("androidx.test:rules:1.5.0")
+                implementation(libs.testExtJunit)
+                implementation(libs.testCore)
+                implementation(libs.testRunner)
+                implementation(libs.testRules)
             }
         }
 
@@ -123,4 +123,7 @@
 
 android {
     namespace "androidx.collection.benchmark"
+    defaultConfig {
+        minSdkVersion 22 // b/294570164
+    }
 }
diff --git a/collection/collection-benchmark/src/androidTest/java/androidx/collection/ScatterMapBenchmarkTest.kt b/collection/collection-benchmark/src/androidTest/java/androidx/collection/ScatterMapBenchmarkTest.kt
new file mode 100644
index 0000000..47cb642
--- /dev/null
+++ b/collection/collection-benchmark/src/androidTest/java/androidx/collection/ScatterMapBenchmarkTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.collection
+
+import androidx.benchmark.junit4.BenchmarkRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.junit.runners.Parameterized.Parameters
+
+@RunWith(Parameterized::class)
+class ScatterMapBenchmarkTest(size: Int) {
+    private val sourceSet = createDataSet(size)
+
+    @get:Rule
+    val benchmark = BenchmarkRule()
+
+    @Test
+    fun insert() {
+        benchmark.runCollectionBenchmark(ScatterMapInsertBenchmark(sourceSet))
+    }
+
+    @Test
+    fun remove() {
+        benchmark.runCollectionBenchmark(ScatterMapRemoveBenchmark(sourceSet))
+    }
+
+    @Test
+    fun read() {
+        benchmark.runCollectionBenchmark(ScatterHashMapReadBenchmark(sourceSet))
+    }
+
+    @Test
+    fun forEach() {
+        benchmark.runCollectionBenchmark(ScatterMapForEachBenchmark(sourceSet))
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameters(name = "size={0}")
+        fun parameters() = buildParameters(
+            listOf(10, 100, 1_000, 16_000)
+        )
+    }
+}
diff --git a/collection/collection-benchmark/src/commonMain/kotlin/androidx/collection/ScatterMapBenchmarks.kt b/collection/collection-benchmark/src/commonMain/kotlin/androidx/collection/ScatterMapBenchmarks.kt
new file mode 100644
index 0000000..8e38d63
--- /dev/null
+++ b/collection/collection-benchmark/src/commonMain/kotlin/androidx/collection/ScatterMapBenchmarks.kt
@@ -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.collection
+
+import kotlin.random.Random
+
+internal class ScatterMapInsertBenchmark(
+    private val dataSet: Array<String>
+) : CollectionBenchmark {
+    override fun measuredBlock() {
+        val map = MutableScatterMap<String, String>(dataSet.size)
+        for (testValue in dataSet) {
+            map[testValue] = testValue
+        }
+    }
+}
+
+internal class ScatterHashMapReadBenchmark(
+    private val dataSet: Array<String>
+) : CollectionBenchmark {
+    private val map = MutableScatterMap<String, String>()
+
+    init {
+        for (testValue in dataSet) {
+            map[testValue] = testValue
+        }
+    }
+
+    override fun measuredBlock() {
+        for (testValue in dataSet) {
+            map[testValue]
+        }
+    }
+}
+
+internal class ScatterMapForEachBenchmark(
+    dataSet: Array<String>
+) : CollectionBenchmark {
+    private val map = MutableScatterMap<String, String>()
+
+    init {
+        for (testValue in dataSet) {
+            map[testValue] = testValue
+        }
+    }
+
+    override fun measuredBlock() {
+        map.forEach { k, v ->
+            @Suppress("UnusedEquals", "RedundantSuppression")
+            k == v
+        }
+    }
+}
+
+internal class ScatterMapRemoveBenchmark(
+    private val dataSet: Array<String>
+) : CollectionBenchmark {
+    private val map = MutableScatterMap<String, String>()
+
+    init {
+        for (testValue in dataSet) {
+            map[testValue] = testValue
+        }
+    }
+
+    override fun measuredBlock() {
+        for (testValue in dataSet) {
+            map.remove(testValue)
+        }
+    }
+}
+
+internal fun createDataSet(
+    size: Int
+): Array<String> = Array(size) { index ->
+    (index * Random.Default.nextFloat()).toString()
+}
diff --git a/collection/collection/api/current.txt b/collection/collection/api/current.txt
index a2fdada..bc80eab 100644
--- a/collection/collection/api/current.txt
+++ b/collection/collection/api/current.txt
@@ -161,6 +161,64 @@
     method public static inline <K, V> androidx.collection.LruCache<K,V> lruCache(int maxSize, optional kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Integer> sizeOf, optional kotlin.jvm.functions.Function1<? super K,? extends V> create, optional kotlin.jvm.functions.Function4<? super java.lang.Boolean,? super K,? super V,? super V,kotlin.Unit> onEntryRemoved);
   }
 
+  public final class MutableScatterMap<K, V> extends androidx.collection.ScatterMap<K,V> {
+    ctor public MutableScatterMap(optional int initialCapacity);
+    method public java.util.Map<K,V> asMutableMap();
+    method public void clear();
+    method public inline V getOrPut(K key, kotlin.jvm.functions.Function0<? extends V> defaultValue);
+    method public inline operator void minusAssign(Iterable<? extends K> keys);
+    method public inline operator void minusAssign(K key);
+    method public inline operator void minusAssign(K![] keys);
+    method public inline operator void minusAssign(kotlin.sequences.Sequence<? extends K> keys);
+    method public inline operator void plusAssign(androidx.collection.ScatterMap<K,V> from);
+    method public inline operator void plusAssign(Iterable<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public inline operator void plusAssign(java.util.Map<K,? extends V> from);
+    method public inline operator void plusAssign(kotlin.Pair<? extends K,? extends V> pair);
+    method public inline operator void plusAssign(kotlin.Pair<? extends K,? extends V>![] pairs);
+    method public inline operator void plusAssign(kotlin.sequences.Sequence<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public V? put(K key, V value);
+    method public void putAll(androidx.collection.ScatterMap<K,V> from);
+    method public void putAll(Iterable<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public void putAll(java.util.Map<K,? extends V> from);
+    method public void putAll(kotlin.Pair<? extends K,? extends V>![] pairs);
+    method public void putAll(kotlin.sequences.Sequence<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public V? remove(K key);
+    method public boolean remove(K key, V value);
+    method public operator void set(K key, V value);
+    method public int trim();
+  }
+
+  public abstract sealed class ScatterMap<K, V> {
+    method public final inline boolean all(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final boolean any();
+    method public final inline boolean any(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final java.util.Map<K,V> asMap();
+    method public final operator boolean contains(K key);
+    method public final boolean containsKey(K key);
+    method public final boolean containsValue(V value);
+    method public final int count();
+    method public final inline int count(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final inline void forEach(kotlin.jvm.functions.Function2<? super K,? super V,kotlin.Unit> block);
+    method public final inline void forEachKey(kotlin.jvm.functions.Function1<? super K,kotlin.Unit> block);
+    method public final inline void forEachValue(kotlin.jvm.functions.Function1<? super V,kotlin.Unit> block);
+    method public final operator V? get(K key);
+    method public final int getCapacity();
+    method public final V getOrDefault(K key, V defaultValue);
+    method public final inline V getOrElse(K key, kotlin.jvm.functions.Function0<? extends V> defaultValue);
+    method public final int getSize();
+    method public final boolean isEmpty();
+    method public final boolean isNotEmpty();
+    method public final boolean none();
+    property public final int capacity;
+    property public final int size;
+  }
+
+  public final class ScatterMapKt {
+    method public static <K, V> androidx.collection.ScatterMap<K,V> emptyScatterMap();
+    method public static <K, V> androidx.collection.MutableScatterMap<K,V> mutableScatterMapOf();
+    method public static <K, V> androidx.collection.MutableScatterMap<K,V> mutableScatterMapOf(kotlin.Pair<? extends K,? extends V>... pairs);
+  }
+
   public class SimpleArrayMap<K, V> {
     ctor public SimpleArrayMap();
     ctor public SimpleArrayMap(androidx.collection.SimpleArrayMap<? extends K,? extends V>? map);
diff --git a/collection/collection/api/restricted_current.txt b/collection/collection/api/restricted_current.txt
index a2fdada..cf53848 100644
--- a/collection/collection/api/restricted_current.txt
+++ b/collection/collection/api/restricted_current.txt
@@ -161,6 +161,76 @@
     method public static inline <K, V> androidx.collection.LruCache<K,V> lruCache(int maxSize, optional kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Integer> sizeOf, optional kotlin.jvm.functions.Function1<? super K,? extends V> create, optional kotlin.jvm.functions.Function4<? super java.lang.Boolean,? super K,? super V,? super V,kotlin.Unit> onEntryRemoved);
   }
 
+  public final class MutableScatterMap<K, V> extends androidx.collection.ScatterMap<K,V> {
+    ctor public MutableScatterMap(optional int initialCapacity);
+    method public java.util.Map<K,V> asMutableMap();
+    method public void clear();
+    method public inline V getOrPut(K key, kotlin.jvm.functions.Function0<? extends V> defaultValue);
+    method public inline operator void minusAssign(Iterable<? extends K> keys);
+    method public inline operator void minusAssign(K key);
+    method public inline operator void minusAssign(K![] keys);
+    method public inline operator void minusAssign(kotlin.sequences.Sequence<? extends K> keys);
+    method public inline operator void plusAssign(androidx.collection.ScatterMap<K,V> from);
+    method public inline operator void plusAssign(Iterable<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public inline operator void plusAssign(java.util.Map<K,? extends V> from);
+    method public inline operator void plusAssign(kotlin.Pair<? extends K,? extends V> pair);
+    method public inline operator void plusAssign(kotlin.Pair<? extends K,? extends V>![] pairs);
+    method public inline operator void plusAssign(kotlin.sequences.Sequence<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public V? put(K key, V value);
+    method public void putAll(androidx.collection.ScatterMap<K,V> from);
+    method public void putAll(Iterable<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public void putAll(java.util.Map<K,? extends V> from);
+    method public void putAll(kotlin.Pair<? extends K,? extends V>![] pairs);
+    method public void putAll(kotlin.sequences.Sequence<? extends kotlin.Pair<? extends K,? extends V>> pairs);
+    method public V? remove(K key);
+    method public boolean remove(K key, V value);
+    method public operator void set(K key, V value);
+    method public int trim();
+  }
+
+  public abstract sealed class ScatterMap<K, V> {
+    method public final inline boolean all(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final boolean any();
+    method public final inline boolean any(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final java.util.Map<K,V> asMap();
+    method public final operator boolean contains(K key);
+    method public final boolean containsKey(K key);
+    method public final boolean containsValue(V value);
+    method public final int count();
+    method public final inline int count(kotlin.jvm.functions.Function2<? super K,? super V,java.lang.Boolean> predicate);
+    method public final inline void forEach(kotlin.jvm.functions.Function2<? super K,? super V,kotlin.Unit> block);
+    method @kotlin.PublishedApi internal final inline void forEachIndexed(kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> block);
+    method public final inline void forEachKey(kotlin.jvm.functions.Function1<? super K,kotlin.Unit> block);
+    method public final inline void forEachValue(kotlin.jvm.functions.Function1<? super V,kotlin.Unit> block);
+    method public final operator V? get(K key);
+    method public final int getCapacity();
+    method public final V getOrDefault(K key, V defaultValue);
+    method public final inline V getOrElse(K key, kotlin.jvm.functions.Function0<? extends V> defaultValue);
+    method public final int getSize();
+    method public final boolean isEmpty();
+    method public final boolean isNotEmpty();
+    method public final boolean none();
+    property public final int capacity;
+    property public final int size;
+    field @kotlin.PublishedApi internal Object![] keys;
+    field @kotlin.PublishedApi internal long[] metadata;
+    field @kotlin.PublishedApi internal Object![] values;
+  }
+
+  public final class ScatterMapKt {
+    method public static <K, V> androidx.collection.ScatterMap<K,V> emptyScatterMap();
+    method @kotlin.PublishedApi internal static inline boolean isFull(long value);
+    method @kotlin.PublishedApi internal static inline int lowestBitSet(long);
+    method @kotlin.PublishedApi internal static inline long maskEmptyOrDeleted(long);
+    method @kotlin.PublishedApi internal static inline long match(long, int m);
+    method public static <K, V> androidx.collection.MutableScatterMap<K,V> mutableScatterMapOf();
+    method public static <K, V> androidx.collection.MutableScatterMap<K,V> mutableScatterMapOf(kotlin.Pair<? extends K,? extends V>... pairs);
+    method @kotlin.PublishedApi internal static inline long readRawMetadata(long[] data, int offset);
+    field @kotlin.PublishedApi internal static final long BitmaskLsb = 72340172838076673L; // 0x101010101010101L
+    field @kotlin.PublishedApi internal static final long BitmaskMsb = -9187201950435737472L; // 0x8080808080808080L
+    field @kotlin.PublishedApi internal static final long Sentinel = 255L; // 0xffL
+  }
+
   public class SimpleArrayMap<K, V> {
     ctor public SimpleArrayMap();
     ctor public SimpleArrayMap(androidx.collection.SimpleArrayMap<? extends K,? extends V>? map);
diff --git a/collection/collection/src/commonMain/kotlin/androidx/collection/ScatterMap.kt b/collection/collection/src/commonMain/kotlin/androidx/collection/ScatterMap.kt
new file mode 100644
index 0000000..3d4e91d
--- /dev/null
+++ b/collection/collection/src/commonMain/kotlin/androidx/collection/ScatterMap.kt
@@ -0,0 +1,1725 @@
+/*
+ * 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:Suppress(
+    "RedundantVisibilityModifier",
+    "KotlinRedundantDiagnosticSuppress",
+    "KotlinConstantConditions",
+    "PropertyName",
+    "ConstPropertyName",
+    "PrivatePropertyName",
+    "NOTHING_TO_INLINE"
+)
+
+package androidx.collection
+
+import androidx.collection.internal.EMPTY_OBJECTS
+import kotlin.jvm.JvmField
+import kotlin.math.max
+
+// A "flat" hash map based on abseil's flat_hash_map
+// (see https://abseil.io/docs/cpp/guides/container). Unlike its C++
+// equivalent, this hash map doesn't (and cannot) store the keys and values
+// directly inside a table. Instead the references and keys are stored in
+// 2 separate tables. The implementation could be made "flatter" by storing
+// both keys and values in the same array but this yields no improvement.
+//
+// The main design goal of this container is to provide a generic, cache-
+// friendly, *allocation free* hash map, with performance on par with
+// LinkedHashMap to act as a suitable replacement for the common
+// mutableMapOf() in Kotlin.
+//
+// The implementation is very similar, and is based, as the name suggests,
+// on a flat table of values. To understand the implementation, let's first
+// define the terminology used throughout this file:
+//
+// - Slot
+//       An entry in the backing table; in practice a slot is a pair of
+//       (key, value) stored in two separate allocations.
+// - Metadata
+//       Indicates the state of a slot (available, etc. see below) but
+//       can also store part of the slot's hash.
+// - Group
+//       Metadata for multiple slots that can be manipulated as a unit to
+//       speed up processing.
+//
+// To quickly and efficiently find any given slot, the implementation uses
+// groups to compare up to 8 entries at a time. To achieve this, we use
+// open-addressing probing quadratic probing
+// (https://en.wikipedia.org/wiki/Quadratic_probing).
+//
+// The table's memory layout is organized around 3 arrays:
+//
+// - metadata
+//       An array of metadata bytes, encoded as a LongArray (see below).
+//       The size of this array depends on capacity, but is smaller since
+//       the array encodes 8 metadata per Long. There is also padding at
+//       the end to permit branchless probing.
+// - keys
+//       Holds references to the key stored in the map. An index i in
+//       this array maps to the corresponding values in the values array.
+//       This array always has the same size as the capacity of the map.
+// - values
+//       Holds references to the key stored in the map. An index i in
+//       this array maps to the corresponding values in the keys array
+//       This array always has the same size as the capacity of the map.
+//
+// A key's hash code is separated into two distinct hashes:
+//
+// - H1: the hash code's 25 most significant bits
+// - H2: the hash code's 7 least significant bits
+//
+// H1 is used as an index into the slots, and a starting point for a probe
+// whenever we need to seek an entry in the table. H2 is used to quickly
+// filter out slots when looking for a specific key in the table.
+//
+// While H1 is used to initiate a probing sequence, it is never stored in
+// the table. H2 is however stored in the metadata of a slot. The metadata
+// for any given slot is a single byte, which can have one of four states:
+//
+// - Empty: unused slot
+// - Deleted: previously used slot
+// - Full: used slot
+// - Sentinel: marker to avoid branching, used to stop iterations
+//
+// They have the following bit patterns:
+//
+//      Empty: 1 0 0 0 0 0 0 0
+//    Deleted: 1 1 1 1 1 1 1 0
+//       Full: 0 h h h h h h h  // h represents the lower 7 hash bits
+//   Sentinel: 1 1 1 1 1 1 1 1
+//
+// Insertions, reads, removals, and replacements all need to perform the
+// same basic operation: finding a specific slot in the table. This `find`
+// operation works like this:
+//
+// - Compute H1 from the key's hash code
+// - Initialize a probe sequence from H1, which will potentially visit
+//   every group in the map (but usually stops at the first one)
+// - For each probe offset, select an entire group (8 entries) and find
+//   candidate slots in that group. This means finding slots with a
+//   matching H2 hash. We then iterate over the matching slots and compare
+//   the slot's key to the find's key. If we have a final match, we know
+//   the index of the key/value pair in the table. If there is no match
+//   and the entire group is empty, the key does not exist in the table.
+//
+// Matching a Group with H2 ensures that one of the matching slots is
+// likely to hold the same key as the one we are looking for. It also lets
+// us quickly skip entire chunks of the map (for instance during iteration
+// if a Group contains only empty slots, we can ignore it entirely).
+//
+// Since the metadata of a slot is made of a single byte, we could use
+// a ByteArray instead of a LongArray. However, using a LongArray makes
+// constructing a group cheaper and guarantees aligned reads. As a result
+// we use a form of virtual addressing: when looking for a group starting
+// at index 3 for instance, we do not fetch the 4th entry in the array of
+// metadata, but instead find the Long that holds the 4th byte and create
+// a Group of 8 bytes starting from that byte. The details are explained
+// below in the group() function.
+//
+// Reference:
+//    Designing a Fast, Efficient, Cache-friendly Hash Table, Step by Step
+//    2017, Matt Kulukundis, https://www.youtube.com/watch?v=ncHmEUmJZf4
+
+// Indicates that all the slot in a [Group] are empty
+// 0x8080808080808080UL, see explanation in [BitmaskMsb]
+private const val AllEmpty = -0x7f7f7f7f7f7f7f80L
+
+private const val Empty = 0b10000000L
+private const val Deleted = 0b11111110L
+
+// Used to mark the end of the actual storage, used to end iterations
+@PublishedApi
+internal const val Sentinel: Long = 0b11111111L
+
+// The number of entries depends on [GroupWidth]. Since our group width
+// is fixed to 8 currently, we add 7 entries after the sentinel. To
+// satisfy the case of a 0 capacity map, we also add another entry full
+// of sentinels. Since our lookups always fetch 2 longs from the array,
+// we make sure we have enough
+@JvmField
+internal val EmptyGroup = longArrayOf(
+    // NOTE: the first byte in the array's logical order is in the LSB
+    -0x7f7f7f7f7f7f7f01L, // Sentinel, Empty, Empty... or 0x80808080808080FFUL
+    -1L // 0xFFFFFFFFFFFFFFFFUL
+)
+
+// Width of a group, in bytes. Since we can only use types as large as
+// Long we must fit our metadata bytes in a 64-bit word or smaller, which
+// means we can only store up to 8 slots in a group. Ideally we could use
+// 128-bit data types to benefit from NEON/SSE instructions and manipulate
+// groups of 16 slots at a time.
+private const val GroupWidth = 8
+
+// A group is made of 8 metadata, or 64 bits
+private typealias Group = Long
+
+// Number of metadata present both at the beginning and at the end of
+// the metadata array so we can use a [GroupWidth] probing window from
+// any index in the table.
+private const val ClonedMetadataCount = GroupWidth - 1
+
+// Capacity to use as the first bump when capacity is initially 0
+// We choose 6 so that the "unloaded" capacity maps to 7
+private const val DefaultCapacity = 6
+
+// Default empty map to avoid allocations
+private val EmptyScatterMap = MutableScatterMap<Any?, Nothing>(0)
+
+/**
+ * Returns an empty, read-only [ScatterMap].
+ */
+@Suppress("UNCHECKED_CAST")
+public fun <K, V> emptyScatterMap(): ScatterMap<K, V> = EmptyScatterMap as ScatterMap<K, V>
+
+/**
+ * Returns a new [MutableScatterMap].
+ */
+public fun <K, V> mutableScatterMapOf(): MutableScatterMap<K, V> = MutableScatterMap()
+
+/**
+ * Returns a new [MutableScatterMap] with the specified contents, given as
+ * a list of pairs where the first component is the key and the second
+ * is the value. If multiple pairs have the same key, the resulting map
+ * will contain the value from the last of those pairs.
+ */
+public fun <K, V> mutableScatterMapOf(vararg pairs: Pair<K, V>): MutableScatterMap<K, V> =
+    MutableScatterMap<K, V>(pairs.size).apply {
+        putAll(pairs)
+    }
+
+/**
+ * [ScatterMap] is a container with a [Map]-like interface based on a flat
+ * hash table implementation (the key/value mappings are not stored by nodes
+ * but directly into arrays). The underlying implementation is designed to avoid
+ * all allocations on insertion, removal, retrieval, and iteration. Allocations
+ * may still happen on insertion when the underlying storage needs to grow to
+ * accommodate newly added entries to the table. In addition, this implementation
+ * minimizes memory usage by avoiding the use of separate objects to hold
+ * key/value pairs.
+ *
+ * This implementation makes no guarantee as to the order of the keys and
+ * values stored, nor does it make guarantees that the order remains constant
+ * over time.
+ *
+ * This implementation is not thread-safe: reads and writes from multiple threads
+ * must be appropriately guarded.
+ *
+ * This implementation is read-only and only allows data to be queried. A
+ * mutable implementation is provided by [MutableScatterMap].
+ *
+ * **Note**: when a [Map] is absolutely necessary, you can use the method
+ * [asMap] to create a thin wrapper around a [ScatterMap]. Please refer to
+ * [asMap] for more details and caveats.
+ *
+ * @see [MutableScatterMap]
+ */
+public sealed class ScatterMap<K, V> {
+    // NOTE: Our arrays are marked internal to implement inlined forEach{}
+    // The backing array for the metadata bytes contains
+    // `capacity + 1 + ClonedMetadataCount` entries, including when
+    // the table is empty (see [EmptyGroup]).
+    @PublishedApi
+    @JvmField
+    internal var metadata: LongArray = EmptyGroup
+
+    @PublishedApi
+    @JvmField
+    internal var keys: Array<Any?> = EMPTY_OBJECTS
+
+    @PublishedApi
+    @JvmField
+    internal var values: Array<Any?> = EMPTY_OBJECTS
+
+    // We use a backing field for capacity to avoid invokevirtual calls
+    // every time we need to look at the capacity
+    @JvmField
+    internal var _capacity: Int = 0
+
+    /**
+     * Returns the number of key-value pairs that can be stored in this map
+     * without requiring internal storage reallocation.
+     */
+    public val capacity: Int
+        get() = _capacity
+
+    // We use a backing field for capacity to avoid invokevirtual calls
+    // every time we need to look at the size
+    @JvmField
+    internal var _size: Int = 0
+
+    /**
+     * Returns the number of key-value pairs in this map.
+     */
+    public val size: Int
+        get() = _size
+
+    // Used by [probeStart] and [probeNext] to move through the table
+    // These fields are marked internal so inlined code does not call
+    // synthetic accessors
+    @JvmField
+    internal var probeMask = -1
+    @JvmField
+    internal var probeOffset = -1
+    @JvmField
+    internal var probeIndex = -1
+
+    /**
+     * Returns `true` if this map has at least one entry.
+     */
+    public fun any(): Boolean = _size != 0
+
+    /**
+     * Returns `true` if this map has no entries.
+     */
+    public fun none(): Boolean = _size == 0
+
+    /**
+     * Indicates whether this map is empty.
+     */
+    public fun isEmpty(): Boolean = _size == 0
+
+    /**
+     * Returns `true` if this map is not empty.
+     */
+    public fun isNotEmpty(): Boolean = _size != 0
+
+    /**
+     * Returns the value corresponding to the given [key], or `null` if such
+     * a key is not present in the map.
+     */
+    public operator fun get(key: K): V? {
+        val index = findKeyIndex(key)
+        @Suppress("UNCHECKED_CAST")
+        return if (index >= 0) values[index] as V? else null
+    }
+
+    /**
+     * Returns the value to which the specified [key] is mapped,
+     * or [defaultValue] if this map contains no mapping for the key.
+     */
+    public fun getOrDefault(key: K, defaultValue: V): V {
+        val index = findKeyIndex(key)
+        if (index >= 0) {
+            @Suppress("UNCHECKED_CAST")
+            return values[index] as V
+        }
+        return defaultValue
+    }
+
+    /**
+     * Returns the value for the given [key] if the value is present
+     * and not null. Otherwise, returns the result of the [defaultValue]
+     * function.
+     */
+    public inline fun getOrElse(key: K, defaultValue: () -> V): V {
+        return get(key) ?: defaultValue()
+    }
+
+    /**
+     * Iterates over every key/value pair stored in this map by invoking
+     * the specified [block] lambda.
+     */
+    @PublishedApi
+    internal inline fun forEachIndexed(block: (index: Int) -> Unit) {
+        val m = metadata
+        val lastIndex = m.size - 2 // We always have 0 or at least 2 entries
+
+        for (i in 0..lastIndex) {
+            var slot = m[i]
+            if (slot.maskEmptyOrDeleted() != BitmaskMsb) {
+                // Branch-less if (i == lastIndex) 7 else 8
+                // i - lastIndex returns a negative value when i < lastIndex,
+                // so 1 is set as the MSB. By inverting and shifting we get
+                // 0 when i < lastIndex, 1 otherwise.
+                val bitCount = 8 - ((i - lastIndex).inv() ushr 31)
+                for (j in 0 until bitCount) {
+                    if (isFull(slot and 0xFFL)) {
+                        val index = (i shl 3) + j
+                        block(index)
+                    }
+                    slot = slot shr 8
+                }
+                if (bitCount != 8) return
+            }
+        }
+    }
+
+    /**
+     * Iterates over every key/value pair stored in this map by invoking
+     * the specified [block] lambda.
+     */
+    public inline fun forEach(block: (key: K, value: V) -> Unit) {
+        val k = keys
+        val v = values
+
+        forEachIndexed { index ->
+            @Suppress("UNCHECKED_CAST")
+            block(k[index] as K, v[index] as V)
+        }
+    }
+
+    /**
+     * Iterates over every key stored in this map by invoking the specified
+     * [block] lambda.
+     */
+    public inline fun forEachKey(block: (key: K) -> Unit) {
+        val k = keys
+
+        forEachIndexed { index ->
+            @Suppress("UNCHECKED_CAST")
+            block(k[index] as K)
+        }
+    }
+
+    /**
+     * Iterates over every value stored in this map by invoking the specified
+     * [block] lambda.
+     */
+    public inline fun forEachValue(block: (value: V) -> Unit) {
+        val v = values
+
+        forEachIndexed { index ->
+            @Suppress("UNCHECKED_CAST")
+            block(v[index] as V)
+        }
+    }
+
+    /**
+     * Returns true if all entries match the given [predicate].
+     */
+    public inline fun all(predicate: (K, V) -> Boolean): Boolean {
+        forEach { key, value ->
+            if (!predicate(key, value)) return false
+        }
+        return true
+    }
+
+    /**
+     * Returns true if at least one entry matches the given [predicate].
+     */
+    public inline fun any(predicate: (K, V) -> Boolean): Boolean {
+        forEach { key, value ->
+            if (predicate(key, value)) return true
+        }
+        return false
+    }
+
+    /**
+     * Returns the number of entries in this map.
+     */
+    public fun count(): Int = size
+
+    /**
+     * Returns the number of entries matching the given [predicate].
+     */
+    public inline fun count(predicate: (K, V) -> Boolean): Int {
+        var count = 0
+        forEach { key, value ->
+            if (predicate(key, value)) count++
+        }
+        return count
+    }
+
+    /**
+     * Returns true if the specified [key] is present in this hash map, false
+     * otherwise.
+     */
+    public operator fun contains(key: K): Boolean = findKeyIndex(key) >= 0
+
+    /**
+     * Returns true if the specified [key] is present in this hash map, false
+     * otherwise.
+     */
+    public fun containsKey(key: K): Boolean = findKeyIndex(key) >= 0
+
+    /**
+     * Returns true if the specified [value] is present in this hash map, false
+     * otherwise.
+     */
+    public fun containsValue(value: V): Boolean {
+        forEachValue { v ->
+            if (value == v) return true
+        }
+        return false
+    }
+
+    /**
+     * Returns the hash code value for this map. The hash code the sum of the hash
+     * codes of each key/value pair.
+     */
+    public override fun hashCode(): Int {
+        var hash = 0
+
+        forEach { key, value ->
+            hash += key.hashCode() xor value.hashCode()
+        }
+
+        return hash
+    }
+
+    /**
+     * Compares the specified object [other] with this hash map for equality.
+     * The two objects are considered equal if [other]:
+     * - Is a [ScatterMap]
+     * - Has the same [size] as this map
+     * - Contains key/value pairs equal to this map's pair
+     */
+    public override fun equals(other: Any?): Boolean {
+        if (other === this) {
+            return true
+        }
+
+        if (other !is ScatterMap<*, *>) {
+            return false
+        }
+        if (other.size != size) {
+            return false
+        }
+
+        @Suppress("UNCHECKED_CAST")
+        val o = other as ScatterMap<Any?, Any?>
+
+        forEach { key, value ->
+            if (value == null) {
+                if (o[key] != null || !o.containsKey(key)) {
+                    return false
+                }
+            } else if (value != o[key]) {
+                return false
+            }
+        }
+
+        return true
+    }
+
+    /**
+     * Returns a string representation of this map. The map is denoted in the
+     * string by the `{}`. Each key/value pair present in the map is represented
+     * inside '{}` by a substring of the form `key=value`, and pairs are
+     * separated by `, `.
+     */
+    public override fun toString(): String {
+        if (isEmpty()) {
+            return "{}"
+        }
+
+        val s = StringBuilder().append('{')
+        var i = 0
+        forEach { key, value ->
+            s.append(if (key === this) "(this)" else key)
+            s.append("=")
+            s.append(if (value === this) "(this)" else value)
+            i++
+            if (i < _size) {
+                s.append(',').append(' ')
+            }
+        }
+
+        return s.append('}').toString()
+    }
+
+    /**
+     * Scans the hash table to find the index in the backing arrays of the
+     * specified [key]. Returns -1 if the key is not present.
+     */
+    internal inline fun findKeyIndex(key: K): Int {
+        val hash = hash(key)
+        val hash2 = h2(hash)
+
+        probeStart(h1(hash), _capacity)
+        while (true) {
+            val g = group(metadata, probeOffset)
+            var m = g.match(hash2)
+            while (m.hasNext()) {
+                val index = probeOffset(m.get())
+                if (keys[index] == key) {
+                    return index
+                }
+                m = m.next()
+            }
+            if (g.maskEmpty() != 0L) {
+                break
+            }
+            probeNext()
+        }
+
+        return -1
+    }
+
+    /**
+     * A probe is a virtual construct used to iterate over the groups in the
+     * hash table in some interesting order. Before probing the table, one must
+     * call this function with the [hash] at which we want to start the probing
+     * and a suitable [mask].
+     *
+     * The sequence is a triangular progression of the form:
+     *
+     * `p(i) = GroupWidth * (i ^ 2 + i) / 2 + hash (mod mask + 1)`
+     *
+     * The first few entries in the metadata table are mirrored at the end of
+     * the table so when we inspect those candidates we must make sure to not
+     * use their offset directly but instead the "wrap around" values, hence
+     * the `mask + 1` modulo.
+     *
+     * The proper usage of this API is as follows:
+     *
+     * ```
+     * probeStart(H1(hash), capacity)
+     * while (true) {
+     *     // visit the group at probeOffset
+     *     probeNext()
+     * }
+     * ```
+     * This probe sequence visits every group exactly once if the number of
+     * groups is a power of two, since `(i ^ 2 + i) / 2` is a bijection in
+     * `Z / (2 ^ m)`. See https://en.wikipedia.org/wiki/Quadratic_probing
+     */
+    internal inline fun probeStart(hash: Int, mask: Int) {
+        probeMask = mask
+        probeOffset = hash and mask
+        probeIndex = 0
+    }
+
+    /**
+     * Moves the probe to the next interesting group to visit. Refer to
+     * [probeStart] for more information.
+     */
+    internal inline fun probeNext() {
+        probeIndex += GroupWidth
+        probeOffset = (probeOffset + probeIndex) and probeMask
+    }
+
+    /**
+     * An offset within our tables starting from the current [probeOffset].
+     */
+    internal inline fun probeOffset(offset: Int) = (probeOffset + offset) and probeMask
+
+    /**
+     * Wraps this [ScatterMap] with a [Map] interface. The [Map] is backed
+     * by the [ScatterMap], so changes to the [ScatterMap] are reflected
+     * in the [Map]. If the [ScatterMap] is modified while an iteration over
+     * the [Map] is in progress, the results of the iteration are undefined.
+     *
+     * **Note**: while this method is useful to use this [ScatterMap] with APIs
+     * accepting [Map] interfaces, it is less efficient to do so than to use
+     * [ScatterMap]'s APIs directly. While the [Map] implementation returned by
+     * this method tries to be as efficient as possible, the semantics of [Map]
+     * may require the allocation of temporary objects for access and iteration.
+     *
+     * **Note**: the semantics of the returned [Map.entries] property is
+     * different from that of a regular [Map] implementation: the [Map.Entry]
+     * returned by the iterator is a single instance that exists for the
+     * lifetime of the iterator, so you can *not* hold on to it after calling
+     * [Iterator.next].</p>
+     */
+    public fun asMap(): Map<K, V> = MapWrapper()
+
+    // TODO: the proliferation of inner classes causes unnecessary code to be
+    //       created. For instance, `entries.size` below requires a total of
+    //       3 `getfield` to resolve the chain of `this` before getting the
+    //       `_size` field. This is likely bad in the various loops like
+    //       `containsAll()` etc. We should probably instead create named
+    //       classes that take a `ScatterMap` as a parameter to refer to it
+    //       directly.
+    internal open inner class MapWrapper : Map<K, V> {
+        override val entries: Set<Map.Entry<K, V>>
+            get() = object : Set<Map.Entry<K, V>>, Map.Entry<K, V> {
+                var current = -1
+
+                override val size: Int get() = this@ScatterMap._size
+
+                override fun isEmpty(): Boolean = this@ScatterMap.isEmpty()
+
+                override fun iterator(): Iterator<Map.Entry<K, V>> {
+                    val set = this
+                    return iterator {
+                        this@ScatterMap.forEachIndexed { index ->
+                            current = index
+                            yield(set)
+                        }
+                    }
+                }
+
+                override fun containsAll(elements: Collection<Map.Entry<K, V>>): Boolean =
+                    elements.all { this@ScatterMap[it.key] == it.value }
+
+                override fun contains(element: Map.Entry<K, V>): Boolean =
+                    this@ScatterMap[element.key] == element.value
+
+                @Suppress("UNCHECKED_CAST")
+                override val key: K get() = this@ScatterMap.keys[current] as K
+
+                @Suppress("UNCHECKED_CAST")
+                override val value: V get() = this@ScatterMap.values[current] as V
+            }
+
+        override val keys: Set<K> get() = object : Set<K> {
+            override val size: Int get() = this@ScatterMap._size
+
+            override fun isEmpty(): Boolean = this@ScatterMap.isEmpty()
+
+            override fun iterator(): Iterator<K> = iterator {
+                this@ScatterMap.forEachKey { key ->
+                    yield(key)
+                }
+            }
+
+            override fun containsAll(elements: Collection<K>): Boolean =
+                elements.all { this@ScatterMap.containsKey(it) }
+
+            override fun contains(element: K): Boolean = this@ScatterMap.containsKey(element)
+        }
+
+        override val values: Collection<V> get() = object : Collection<V> {
+            override val size: Int get() = this@ScatterMap._size
+
+            override fun isEmpty(): Boolean = this@ScatterMap.isEmpty()
+
+            override fun iterator(): Iterator<V> = iterator {
+                this@ScatterMap.forEachValue { value ->
+                    yield(value)
+                }
+            }
+
+            override fun containsAll(elements: Collection<V>): Boolean =
+                elements.all { this@ScatterMap.containsValue(it) }
+
+            override fun contains(element: V): Boolean = this@ScatterMap.containsValue(element)
+        }
+
+        override val size: Int get() = this@ScatterMap._size
+
+        override fun isEmpty(): Boolean = this@ScatterMap.isEmpty()
+
+        // TODO: @Suppress required because of a lint check issue (b/294130025)
+        override fun get(@Suppress("MissingNullability") key: K): V? = this@ScatterMap[key]
+
+        override fun containsValue(value: V): Boolean = this@ScatterMap.containsValue(value)
+
+        override fun containsKey(key: K): Boolean = this@ScatterMap.containsKey(key)
+    }
+}
+
+/**
+ * [MutableScatterMap] is a container with a [Map]-like interface based on a flat
+ * hash table implementation (the key/value mappings are not stored by nodes
+ * but directly into arrays). The underlying implementation is designed to avoid
+ * all allocations on insertion, removal, retrieval, and iteration. Allocations
+ * may still happen on insertion when the underlying storage needs to grow to
+ * accommodate newly added entries to the table. In addition, this implementation
+ * minimizes memory usage by avoiding the use of separate objects to hold
+ * key/value pairs.
+ *
+ * This implementation makes no guarantee as to the order of the keys and
+ * values stored, nor does it make guarantees that the order remains constant
+ * over time.
+ *
+ * This implementation is not thread-safe: reads and writes from multiple threads
+ * must be appropriately guarded.
+ *
+ * **Note**: when a [Map] is absolutely necessary, you can use the method
+ * [asMap] to create a thin wrapper around a [MutableScatterMap]. Please refer
+ * to [asMap] for more details and caveats.
+ *
+ * **Note**: when a [MutableMap] is absolutely necessary, you can use the
+ * method [asMutableMap] to create a thin wrapper around a [MutableScatterMap].
+ * Please refer to [asMutableMap] for more details and caveats.
+ *
+ * @constructor Creates a new [MutableScatterMap]
+ * @param initialCapacity The initial desired capacity for this container.
+ * the container will honor this value by guaranteeing its internal structures
+ * can hold that many entries without requiring any allocations. The initial
+ * capacity can be set to 0.
+ *
+ * @see Map
+ */
+public class MutableScatterMap<K, V>(
+    initialCapacity: Int = DefaultCapacity
+) : ScatterMap<K, V>() {
+    // Number of entries we can add before we need to grow
+    private var growthLimit = 0
+
+    init {
+        require(initialCapacity >= 0) { "Capacity must be a positive value." }
+        initializeStorage(unloadedCapacity(initialCapacity))
+    }
+
+    private fun initializeStorage(initialCapacity: Int) {
+        val newCapacity = if (initialCapacity > 0) {
+            // Since we use longs for storage, our capacity is never < 7, enforce
+            // it here. We do have a special case for 0 to create small empty maps
+            max(7, normalizeCapacity(initialCapacity))
+        } else {
+            0
+        }
+        _capacity = newCapacity
+        initializeMetadata(newCapacity)
+        keys = arrayOfNulls(newCapacity)
+        values = arrayOfNulls(newCapacity)
+    }
+
+    private fun initializeMetadata(capacity: Int) {
+        metadata = if (capacity == 0) {
+            EmptyGroup
+        } else {
+            // Round up to the next multiple of 8 and find how many longs we need
+            val size = (((capacity + 1 + ClonedMetadataCount) + 7) and 0x7.inv()) shr 3
+            LongArray(size).apply {
+                fill(AllEmpty)
+            }
+        }
+        writeRawMetadata(metadata, capacity, Sentinel)
+        initializeGrowth()
+    }
+
+    private fun initializeGrowth() {
+        growthLimit = loadedCapacity(capacity) - _size
+    }
+
+    /**
+     * Returns the value to which the specified [key] is mapped,
+     * if the value is present in the map and not `null`. Otherwise,
+     * calls `defaultValue()` and puts the result in the map associated
+     * with [key].
+     */
+    public inline fun getOrPut(key: K, defaultValue: () -> V): V {
+        return get(key) ?: defaultValue().also { set(key, it) }
+    }
+
+    /**
+     * Creates a new mapping from [key] to [value] in this map. If [key] is
+     * already present in the map, the association is modified and the previously
+     * associated value is replaced with [value]. If [key] is not present, a new
+     * entry is added to the map, which may require to grow the underlying storage
+     * and cause allocations.
+     */
+    public operator fun set(key: K, value: V) {
+        val index = findAbsoluteInsertIndex(key)
+        keys[index] = key
+        values[index] = value
+    }
+
+    /**
+     * Creates a new mapping from [key] to [value] in this map. If [key] is
+     * already present in the map, the association is modified and the previously
+     * associated value is replaced with [value]. If [key] is not present, a new
+     * entry is added to the map, which may require to grow the underlying storage
+     * and cause allocations. Return the previous value associated with the [key],
+     * or `null` if the key was not present in the map.
+     */
+    public fun put(key: K, value: V): V? {
+        var index = findInsertIndex(key)
+        val oldValue = if (index < 0) {
+            index = -index
+            // New entry, we must add the key
+            keys[index] = key
+            null
+        } else {
+            // Existing entry, we can keep the key
+            values[index]
+        }
+        values[index] = value
+
+        @Suppress("UNCHECKED_CAST")
+        return oldValue as V?
+    }
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public fun putAll(@Suppress("ArrayReturn") pairs: Array<out Pair<K, V>>) {
+        for ((key, value) in pairs) {
+            this[key] = value
+        }
+    }
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public fun putAll(pairs: Iterable<Pair<K, V>>) {
+        for ((key, value) in pairs) {
+            this[key] = value
+        }
+    }
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public fun putAll(pairs: Sequence<Pair<K, V>>) {
+        for ((key, value) in pairs) {
+            this[key] = value
+        }
+    }
+
+    /**
+     * Puts all the key/value mappings in the [from] map into this map.
+     */
+    public fun putAll(from: Map<K, V>) {
+        from.forEach { (key, value) ->
+            this[key] = value
+        }
+    }
+
+    /**
+     * Puts all the key/value mappings in the [from] map into this map.
+     */
+    public fun putAll(from: ScatterMap<K, V>) {
+        from.forEach { key, value ->
+            this[key] = value
+        }
+    }
+
+    /**
+     * Puts the key/value mapping from the [pair] in this map, using the first
+     * element as the key, and the second element as the value.
+     */
+    public inline operator fun plusAssign(pair: Pair<K, V>) {
+        this[pair.first] = pair.second
+    }
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public inline operator fun plusAssign(
+        @Suppress("ArrayReturn") pairs: Array<out Pair<K, V>>
+    ): Unit = putAll(pairs)
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public inline operator fun plusAssign(pairs: Iterable<Pair<K, V>>): Unit = putAll(pairs)
+
+    /**
+     * Puts all the [pairs] into this map, using the first component of the pair
+     * as the key, and the second component as the value.
+     */
+    public inline operator fun plusAssign(pairs: Sequence<Pair<K, V>>): Unit = putAll(pairs)
+
+    /**
+     * Puts all the key/value mappings in the [from] map into this map.
+     */
+    public inline operator fun plusAssign(from: Map<K, V>): Unit = putAll(from)
+
+    /**
+     * Puts all the key/value mappings in the [from] map into this map.
+     */
+    public inline operator fun plusAssign(from: ScatterMap<K, V>): Unit = putAll(from)
+
+    /**
+     * Removes the specified [key] and its associated value from the map. If the
+     * [key] was present in the map, this function returns the value that was
+     * present before removal.
+     */
+    public fun remove(key: K): V? {
+        val index = findKeyIndex(key)
+        if (index >= 0) {
+            return removeValueAt(index)
+        }
+        return null
+    }
+
+    /**
+     * Removes the specified [key] and its associated value from the map if the
+     * associated value equals [value]. Returns whether the removal happened.
+     */
+    public fun remove(key: K, value: V): Boolean {
+        val index = findKeyIndex(key)
+        if (index >= 0) {
+            if (values[index] == value) {
+                removeValueAt(index)
+                return true
+            }
+        }
+        return false
+    }
+
+    /**
+     * Removes the specified [key] and its associated value from the map.
+     */
+    public inline operator fun minusAssign(key: K) {
+        remove(key)
+    }
+
+    /**
+     * Removes the specified [keys] and their associated value from the map.
+     */
+    public inline operator fun minusAssign(@Suppress("ArrayReturn") keys: Array<out K>) {
+        for (key in keys) {
+            remove(key)
+        }
+    }
+
+    /**
+     * Removes the specified [keys] and their associated value from the map.
+     */
+    public inline operator fun minusAssign(keys: Iterable<K>) {
+        for (key in keys) {
+            remove(key)
+        }
+    }
+
+    /**
+     * Removes the specified [keys] and their associated value from the map.
+     */
+    public inline operator fun minusAssign(keys: Sequence<K>) {
+        for (key in keys) {
+            remove(key)
+        }
+    }
+
+    private fun removeValueAt(index: Int): V? {
+        _size -= 1
+
+        // TODO: We could just mark the entry as empty if there's a group
+        //       window around this entry that was already empty
+        writeMetadata(index, Deleted)
+        keys[index] = null
+        val oldValue = values[index]
+        values[index] = null
+
+        @Suppress("UNCHECKED_CAST")
+        return oldValue as V?
+    }
+
+    /**
+     * Removes all mappings from this map.
+     */
+    public fun clear() {
+        _size = 0
+        if (metadata !== EmptyGroup) {
+            metadata.fill(AllEmpty)
+            writeRawMetadata(metadata, _capacity, Sentinel)
+        }
+        values.fill(null, 0, _capacity)
+        keys.fill(null, 0, _capacity)
+        initializeGrowth()
+    }
+
+    /**
+     * Scans the hash table to find the index at which we can store a value
+     * for the give [key]. If the key already exists in the table, its index
+     * will be returned, otherwise the index of an empty slot will be returned.
+     * Calling this function may cause the internal storage to be reallocated
+     * if the table is full.
+     */
+    private fun findAbsoluteInsertIndex(key: K): Int {
+        val hash = hash(key)
+        val hash1 = h1(hash)
+        val hash2 = h2(hash)
+
+        probeStart(hash1, _capacity)
+        while (true) {
+            val g = group(metadata, probeOffset)
+            var m = g.match(hash2)
+            while (m.hasNext()) {
+                val index = probeOffset(m.get())
+                if (keys[index] == key) {
+                    return index
+                }
+                m = m.next()
+            }
+            if (g.maskEmpty() != 0L) {
+                break
+            }
+            probeNext()
+        }
+
+        var index = findFirstAvailableSlot(hash1)
+        if (growthLimit == 0 && !isDeleted(metadata, index)) {
+            adjustStorage()
+            index = findFirstAvailableSlot(hash1)
+        }
+
+        _size += 1
+        growthLimit -= if (isEmpty(metadata, index)) 1 else 0
+        writeMetadata(index, hash2.toLong())
+
+        return index
+    }
+
+    /**
+     * Equivalent of [findInsertIndex] but the returned index is *negative*
+     * if insertion requires a new mapping, and positive if the value takes
+     * place of an existing mapping.
+     */
+    private fun findInsertIndex(key: K): Int {
+        val hash = hash(key)
+        val hash1 = h1(hash)
+        val hash2 = h2(hash)
+
+        probeStart(hash1, _capacity)
+        while (true) {
+            val g = group(metadata, probeOffset)
+            var m = g.match(hash2)
+            while (m.hasNext()) {
+                val index = probeOffset(m.get())
+                if (keys[index] == key) {
+                    return index
+                }
+                m = m.next()
+            }
+            if (g.maskEmpty() != 0L) {
+                break
+            }
+            probeNext()
+        }
+
+        var index = findFirstAvailableSlot(hash1)
+        if (growthLimit == 0 && !isDeleted(metadata, index)) {
+            adjustStorage()
+            index = findFirstAvailableSlot(hash1)
+        }
+
+        _size += 1
+        growthLimit -= if (isEmpty(metadata, index)) 1 else 0
+        writeMetadata(index, hash2.toLong())
+
+        return -index
+    }
+
+    /**
+     * Finds the first empty or deleted slot in the table in which we can
+     * store a value without resizing the internal storage.
+     */
+    private fun findFirstAvailableSlot(hash1: Int): Int {
+        probeStart(hash1, _capacity)
+        while (true) {
+            val g = group(metadata, probeOffset)
+            val m = g.maskEmptyOrDeleted()
+            if (m != 0L) {
+                return probeOffset(m.lowestBitSet())
+            }
+            probeNext()
+        }
+    }
+
+    /**
+     * Trims this [MutableScatterMap]'s storage so it is sized appropriately
+     * to hold the current mappings.
+     *
+     * Returns the number of empty entries removed from this map's storage.
+     * Returns be 0 if no trimming is necessary or possible.
+     */
+    public fun trim(): Int {
+        val previousCapacity = _capacity
+        val newCapacity = normalizeCapacity(unloadedCapacity(_size))
+        if (newCapacity < previousCapacity) {
+            resizeStorage(newCapacity)
+            return previousCapacity - _capacity
+        }
+        return 0
+    }
+
+    /**
+     * Grow internal storage if necessary. This function can instead opt to
+     * remove deleted entries from the table to avoid an expensive reallocation
+     * of the underlying storage. This "rehash in place" occurs when the
+     * current size is <= 25/32 of the table capacity. The choice of 25/32 is
+     * detailed in the implementation of abseil's `raw_hash_set`.
+     */
+    private fun adjustStorage() {
+        if (_capacity > GroupWidth && _size.toULong() * 32UL <= _capacity.toULong() * 25UL) {
+            // TODO: Avoid resize and drop deletes instead
+            resizeStorage(nextCapacity(_capacity))
+        } else {
+            resizeStorage(nextCapacity(_capacity))
+        }
+    }
+
+    private fun resizeStorage(newCapacity: Int) {
+        val previousMetadata = metadata
+        val previousKeys = keys
+        val previousValues = values
+        val previousCapacity = _capacity
+
+        initializeStorage(newCapacity)
+
+        val newKeys = keys
+        val newValues = values
+
+        for (i in 0 until previousCapacity) {
+            if (isFull(previousMetadata, i)) {
+                val previousKey = previousKeys[i]
+                val hash = hash(previousKey)
+                val index = findFirstAvailableSlot(h1(hash))
+
+                writeMetadata(index, h2(hash).toLong())
+                newKeys[index] = previousKey
+                newValues[index] = previousValues[i]
+            }
+        }
+    }
+
+    /**
+     * Writes the "H2" part of an entry into the metadata array at the specified
+     * [index]. The index must be a valid index. This function ensures the
+     * metadata is also written in the clone area at the end.
+     */
+    private inline fun writeMetadata(index: Int, value: Long) {
+        val m = metadata
+        writeRawMetadata(m, index, value)
+
+        // Mirroring
+        val c = _capacity
+        val cloneIndex = ((index - ClonedMetadataCount) and c) +
+            (ClonedMetadataCount and c)
+        writeRawMetadata(m, cloneIndex, value)
+    }
+
+    /**
+     * Wraps this [ScatterMap] with a [MutableMap] interface. The [MutableMap]
+     * is backed by the [ScatterMap], so changes to the [ScatterMap] are
+     * reflected in the [MutableMap] and vice-versa. If the [ScatterMap] is
+     * modified while an iteration over the [MutableMap] is in progress (and vice-
+     * versa), the results of the iteration are undefined.
+     *
+     * **Note**: while this method is useful to use this [MutableScatterMap]
+     * with APIs accepting [MutableMap] interfaces, it is less efficient to do
+     * so than to use [MutableScatterMap]'s APIs directly. While the [MutableMap]
+     * implementation returned by this method tries to be as efficient as possible,
+     * the semantics of [MutableMap] may require the allocation of temporary
+     * objects for access and iteration.
+     *
+     * **Note**: the semantics of the returned [MutableMap.entries] property is
+     * different from that of a regular [MutableMap] implementation: the
+     * [MutableMap.MutableEntry] returned by the iterator is a single instance
+     * that exists for the lifetime of the iterator, so you can *not* hold on to
+     * it after calling [Iterator.next].</p>
+     */
+    public fun asMutableMap(): MutableMap<K, V> = MutableMapWrapper()
+
+    // TODO: See TODO on `MapWrapper`
+    private inner class MutableMapWrapper : MapWrapper(), MutableMap<K, V> {
+        override val entries: MutableSet<MutableMap.MutableEntry<K, V>>
+            get() = object : MutableSet<MutableMap.MutableEntry<K, V>> {
+                override val size: Int get() = this@MutableScatterMap._size
+
+                override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty()
+
+                override fun iterator(): MutableIterator<MutableMap.MutableEntry<K, V>> =
+                    object : MutableIterator<MutableMap.MutableEntry<K, V>>,
+                        MutableMap.MutableEntry<K, V> {
+
+                        var iterator: Iterator<MutableMap.MutableEntry<K, V>>
+                        var current = -1
+
+                        init {
+                            val set = this
+                            iterator = iterator {
+                                this@MutableScatterMap.forEachIndexed { index ->
+                                    current = index
+                                    yield(set)
+                                }
+                            }
+                        }
+
+                        override fun hasNext(): Boolean = iterator.hasNext()
+
+                        override fun next(): MutableMap.MutableEntry<K, V> = iterator.next()
+
+                        override fun remove() {
+                            if (current != -1) {
+                                this@MutableScatterMap.removeValueAt(current)
+                                current = -1
+                            }
+                        }
+
+                        @Suppress("UNCHECKED_CAST")
+                        override val key: K get() = this@MutableScatterMap.keys[current] as K
+
+                        @Suppress("UNCHECKED_CAST")
+                        override val value: V get() = this@MutableScatterMap.values[current] as V
+
+                        override fun setValue(newValue: V): V {
+                            val oldValue = this@MutableScatterMap.values[current]
+                            this@MutableScatterMap.values[current] = newValue
+                            @Suppress("UNCHECKED_CAST")
+                            return oldValue as V
+                        }
+                    }
+
+                override fun clear() {
+                    this@MutableScatterMap.clear()
+                }
+
+                override fun containsAll(
+                    elements: Collection<MutableMap.MutableEntry<K, V>>
+                ): Boolean {
+                    return elements.all { this@MutableScatterMap[it.key] == it.value }
+                }
+
+                override fun contains(element: MutableMap.MutableEntry<K, V>): Boolean =
+                    this@MutableScatterMap[element.key] == element.value
+
+                override fun addAll(elements: Collection<MutableMap.MutableEntry<K, V>>): Boolean {
+                    throw UnsupportedOperationException()
+                }
+
+                override fun add(element: MutableMap.MutableEntry<K, V>): Boolean {
+                    throw UnsupportedOperationException()
+                }
+
+                override fun retainAll(
+                    elements: Collection<MutableMap.MutableEntry<K, V>>
+                ): Boolean {
+                    var changed = false
+                    this@MutableScatterMap.forEachIndexed { index ->
+                        var found = false
+                        for (entry in elements) {
+                            if (entry.key == this@MutableScatterMap.keys[index] &&
+                                entry.value == this@MutableScatterMap.values[index]
+                            ) {
+                                found = true
+                                break
+                            }
+                        }
+                        if (!found) {
+                            removeValueAt(index)
+                            changed = true
+                        }
+                    }
+                    return changed
+                }
+
+                override fun removeAll(
+                    elements: Collection<MutableMap.MutableEntry<K, V>>
+                ): Boolean {
+                    var changed = false
+                    this@MutableScatterMap.forEachIndexed { index ->
+                        for (entry in elements) {
+                            if (entry.key == this@MutableScatterMap.keys[index] &&
+                                entry.value == this@MutableScatterMap.values[index]
+                            ) {
+                                removeValueAt(index)
+                                changed = true
+                                break
+                            }
+                        }
+                    }
+                    return changed
+                }
+
+                override fun remove(element: MutableMap.MutableEntry<K, V>): Boolean {
+                    val index = findKeyIndex(element.key)
+                    if (index >= 0 && this@MutableScatterMap.values[index] == element.value) {
+                        removeValueAt(index)
+                        return true
+                    }
+                    return false
+                }
+            }
+
+        override val keys: MutableSet<K> get() = object : MutableSet<K> {
+            override val size: Int get() = this@MutableScatterMap._size
+
+            override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty()
+
+            override fun iterator(): MutableIterator<K> = object : MutableIterator<K> {
+                private val iterator = iterator {
+                    this@MutableScatterMap.forEachIndexed { index ->
+                        yield(index)
+                    }
+                }
+                private var current: Int = -1
+
+                override fun hasNext(): Boolean = iterator.hasNext()
+
+                override fun next(): K {
+                    current = iterator.next()
+                    @Suppress("UNCHECKED_CAST")
+                    return this@MutableScatterMap.keys[current] as K
+                }
+
+                override fun remove() {
+                    if (current >= 0) {
+                        this@MutableScatterMap.removeValueAt(current)
+                        current = -1
+                    }
+                }
+            }
+
+            override fun clear() {
+                this@MutableScatterMap.clear()
+            }
+
+            override fun addAll(elements: Collection<K>): Boolean {
+                throw UnsupportedOperationException()
+            }
+
+            override fun add(element: K): Boolean {
+                throw UnsupportedOperationException()
+            }
+
+            override fun retainAll(elements: Collection<K>): Boolean {
+                var changed = false
+                this@MutableScatterMap.forEachIndexed { index ->
+                    if (this@MutableScatterMap.keys[index] !in elements) {
+                        removeValueAt(index)
+                        changed = true
+                    }
+                }
+                return changed
+            }
+
+            override fun removeAll(elements: Collection<K>): Boolean {
+                var changed = false
+                this@MutableScatterMap.forEachIndexed { index ->
+                    if (this@MutableScatterMap.keys[index] in elements) {
+                        removeValueAt(index)
+                        changed = true
+                    }
+                }
+                return changed
+            }
+
+            override fun remove(element: K): Boolean {
+                val index = findKeyIndex(element)
+                if (index >= 0) {
+                    removeValueAt(index)
+                    return true
+                }
+                return false
+            }
+
+            override fun containsAll(elements: Collection<K>): Boolean =
+                elements.all { this@MutableScatterMap.containsKey(it) }
+
+            override fun contains(element: K): Boolean =
+                this@MutableScatterMap.containsKey(element)
+        }
+
+        override val values: MutableCollection<V> get() = object : MutableCollection<V> {
+            override val size: Int get() = this@MutableScatterMap._size
+
+            override fun isEmpty(): Boolean = this@MutableScatterMap.isEmpty()
+
+            override fun iterator(): MutableIterator<V> = object : MutableIterator<V> {
+                private val iterator = iterator {
+                    this@MutableScatterMap.forEachIndexed { index ->
+                        yield(index)
+                    }
+                }
+                private var current: Int = -1
+
+                override fun hasNext(): Boolean = iterator.hasNext()
+
+                override fun next(): V {
+                    current = iterator.next()
+                    @Suppress("UNCHECKED_CAST")
+                    return this@MutableScatterMap.values[current] as V
+                }
+
+                override fun remove() {
+                    if (current >= 0) {
+                        this@MutableScatterMap.removeValueAt(current)
+                        current = -1
+                    }
+                }
+            }
+
+            override fun clear() {
+                this@MutableScatterMap.clear()
+            }
+
+            override fun addAll(elements: Collection<V>): Boolean {
+                throw UnsupportedOperationException()
+            }
+
+            override fun add(element: V): Boolean {
+                throw UnsupportedOperationException()
+            }
+
+            override fun retainAll(elements: Collection<V>): Boolean {
+                var changed = false
+                this@MutableScatterMap.forEachIndexed { index ->
+                    if (this@MutableScatterMap.values[index] !in elements) {
+                        removeValueAt(index)
+                        changed = true
+                    }
+                }
+                return changed
+            }
+
+            override fun removeAll(elements: Collection<V>): Boolean {
+                var changed = false
+                this@MutableScatterMap.forEachIndexed { index ->
+                    if (this@MutableScatterMap.values[index] in elements) {
+                        removeValueAt(index)
+                        changed = true
+                    }
+                }
+                return changed
+            }
+
+            override fun remove(element: V): Boolean {
+                this@MutableScatterMap.forEachIndexed { index ->
+                    if (this@MutableScatterMap.values[index] == element) {
+                        removeValueAt(index)
+                        return true
+                    }
+                }
+                return false
+            }
+
+            override fun containsAll(elements: Collection<V>): Boolean =
+                elements.all { this@MutableScatterMap.containsValue(it) }
+
+            override fun contains(element: V): Boolean =
+                this@MutableScatterMap.containsValue(element)
+        }
+
+        override fun clear() {
+            this@MutableScatterMap.clear()
+        }
+
+        override fun remove(key: K): V? = this@MutableScatterMap.remove(key)
+
+        override fun putAll(from: Map<out K, V>) {
+            from.forEach { (key, value) ->
+                this[key] = value
+            }
+        }
+
+        override fun put(key: K, value: V): V? = this@MutableScatterMap.put(key, value)
+    }
+}
+
+/**
+ * Returns the hash code of [k]. This follows the [HashMap] default behavior on Android
+ * of returning [Object.hashcode()] with the higher bits of hash spread to the lower bits.
+ */
+private inline fun hash(k: Any?): Int {
+    val hash = k.hashCode()
+    return hash xor (hash ushr 16)
+}
+
+// Returns the "H1" part of the specified hash code. In our implementation,
+// it is simply the top-most 25 bits
+private inline fun h1(hash: Int) = hash ushr 7
+
+// Returns the "H2" part of the specified hash code. In our implementation,
+// this corresponds to the lower 7 bits
+private inline fun h2(hash: Int) = hash and 0x7F
+
+// Assumes [capacity] was normalized with [normalizedCapacity].
+// Returns the next 2^m - 1
+private fun nextCapacity(capacity: Int) = if (capacity == 0) DefaultCapacity else capacity * 2 + 1
+
+// n -> nearest 2^m - 1
+private fun normalizeCapacity(n: Int) =
+    if (n > 0) (0xFFFFFFFF.toInt() ushr n.countLeadingZeroBits()) else 0
+
+// Computes the growth based on a load factor of 7/8 for the general case.
+// When capacity is < GroupWidth - 1, we use a load factor of 1 instead
+private fun loadedCapacity(capacity: Int): Int {
+    // Special cases where x - x / 8 fails
+    if (GroupWidth <= 8 && capacity == 7) {
+        return 6
+    }
+    // If capacity is < GroupWidth - 1 we end up here and this formula
+    // will return `capacity` in this case, which is what we want
+    return capacity - capacity / 8
+}
+
+// Inverse of loadedCapacity()
+private fun unloadedCapacity(capacity: Int): Int {
+    // Special cases where x + (x - 1) / 7
+    if (GroupWidth <= 8 && capacity == 7) {
+        return 8
+    }
+    return capacity + (capacity - 1) / 7
+}
+
+/**
+ * Reads a single byte from the long array at the specified [offset] in *bytes*.
+ */
+@PublishedApi
+internal inline fun readRawMetadata(data: LongArray, offset: Int): Long {
+    // Take the Long at index `offset / 8` and shift by `offset % 8`
+    // A longer explanation can be found in [group()].
+    return (data[offset shr 3] shr ((offset and 0x7) shl 3)) and 0xFF
+}
+
+/**
+ * Writes a single byte into the long array at the specified [offset] in *bytes*.
+ * NOTE: [value] must be a single byte, accepted here as a Long to avoid
+ * unnecessary conversions.
+ */
+private inline fun writeRawMetadata(data: LongArray, offset: Int, value: Long) {
+    // See [group()] for details. First find the index i in the LongArray,
+    // then find the number of bits we need to shift by
+    val i = offset shr 3
+    val b = (offset and 0x7) shl 3
+    // Mask the source data with 0xFF in the right place, then and [value]
+    // moved to the right spot
+    data[i] = (data[i] and (0xFFL shl b).inv()) or (value shl b)
+}
+
+private inline fun isEmpty(metadata: LongArray, index: Int) =
+    readRawMetadata(metadata, index) == Empty
+private inline fun isDeleted(metadata: LongArray, index: Int) =
+    readRawMetadata(metadata, index) == Deleted
+
+private inline fun isFull(metadata: LongArray, index: Int): Boolean =
+    readRawMetadata(metadata, index) < 0x80L
+
+@PublishedApi
+internal inline fun isFull(value: Long): Boolean = value < 0x80L
+
+// Bitmasks in our context are abstract bitmasks. They represent a bitmask
+// for a Group. i.e. bit 1 is the second least significant byte in the group.
+// These bits are also called "abstract bits". For example, given the
+// following group of metadata and a group width of 8:
+//
+// 0x7700550033001100
+//   |   |   |   | |___ bit 0 = 0x00
+//   |   |   |   |_____ bit 1 = 0x11
+//   |   |   |_________ bit 3 = 0x33
+//   |   |_____________ bit 5 = 0x55
+//   |_________________ bit 7 = 0x77
+//
+// This is useful when performing group operations to figure out, for
+// example, which metadata is set or not.
+//
+// A static bitmask is a read-only bitmask that allows performing simple
+// queries such as [lowestBitSet].
+private typealias StaticBitmask = Long
+// A dynamic bitmask is a bitmask that can be iterated on to retrieve,
+// for instance, the index of all the "abstract bits" set on the group.
+// This assumes the abstract bits are set to either 0x00 (for unset) and
+// 0x80 (for set).
+private typealias Bitmask = Long
+
+@PublishedApi
+internal inline fun StaticBitmask.lowestBitSet(): Int = countTrailingZeroBits() shr 3
+
+/**
+ * Returns the index of the next set bit in this mask. If invoked before checking
+ * [hasNext], this function returns an invalid index (8).
+ */
+private inline fun Bitmask.get() = lowestBitSet()
+
+/**
+ * Moves to the next set bit and returns the modified bitmask, call [get] to
+ * get the actual index. If this function is called before checking [hasNext],
+ * the result is invalid.
+ */
+private inline fun Bitmask.next() = this and (this - 1L)
+
+/**
+ * Returns true if this [Bitmask] contains more set bits.
+ */
+private inline fun Bitmask.hasNext() = this != 0L
+
+// Least significant bits in the bitmask, one for each metadata in the group
+@PublishedApi
+internal const val BitmaskLsb: Long = 0x0101010101010101L
+
+// Most significant bits in the bitmask, one for each metadata in the group
+//
+// NOTE: Ideally we'd use a ULong here, defined as 0x8080808080808080UL but
+// using ULong/UByte makes us take a ~10% performance hit on get/set compared to
+// a Long. And since Kotlin hates signed constants, we have to use
+// -0x7f7f7f7f7f7f7f80L instead of the more sensible 0x8080808080808080L (and
+// 0x8080808080808080UL.toLong() isn't considered a constant)
+@PublishedApi
+internal const val BitmaskMsb: Long = -0x7f7f7f7f7f7f7f80L // srsly Kotlin @#!
+
+/**
+ * Creates a [Group] from a metadata array, starting at the specified offset.
+ * [offset] must be a valid index in the source array.
+ */
+private inline fun group(metadata: LongArray, offset: Int): Group {
+    // A Group is a Long read at an arbitrary byte-grained offset inside the
+    // Long array. To read the Group, we need to read 2 Longs: one for the
+    // most significant bits (MSBs) and one for the least significant bits
+    // (LSBs).
+    // Let's take an example, with a LongArray of 2 and an offset set to 1
+    // byte. We need to read 7 bytes worth of LSBs in Long 0 and 1 byte worth
+    // of MSBs in Long 1 (remember we index the bytes from LSB to MSB so in
+    // the example below byte 0 is 0x11 and byte 11 is 0xAA):
+    //
+    //  ___________________ LongArray ____________________
+    // |                                                  |
+    // [88 77 66 55 44 33 22 11], [FF EE DD CC BB AA 00 99]
+    // |_________Long0_______ _|  |_________Long1_______ _|
+    //
+    // To retrieve the Group we first find the index of Long0 by taking the
+    // offset divided by 0. Then offset modulo 8 gives us how many bits we
+    // need to shift by. With offset = 1:
+    //
+    // index = offset / 8 == 0
+    // remainder = offset % 8 == 1
+    // bitsToShift = remainder * 8
+    //
+    // LSBs = LongArray[index] >>> bitsToShift
+    // MSBs = LongArray[index + 1] << (64 - bitsToShift)
+    //
+    // We now have:
+    //
+    // LSBs == 0x0088776655443322
+    // MSBs == 0x9900000000000000
+    //
+    // However we can't just combine MSBs and LSBs with an OR when the offset
+    // is a multiple of 8, because we would be attempting to shift left by 64
+    // which is a no-op. This means we need to mask the MSBs with 0x0 when
+    // offset is 0, and with 0xFF…FF when offset is != 0. We do this by taking
+    // the negative value of `bitsToShift`, which will set the MSB when the value
+    // is not 0, and doing a signed shift to the right to duplicate it:
+    //
+    // Group = LSBs | (MSBs & (-b >> 63)
+    //
+    // Note: since b is only ever 0, 8, 16, 24, 32, 48, 56, or 64, we don't
+    // need to shift by 63, we could shift by only 5
+    val i = offset shr 3
+    val b = (offset and 0x7) shl 3
+    return (metadata[i] ushr b) or (metadata[i + 1] shl (64 - b) and (-(b.toLong()) shr 63))
+}
+
+/**
+ * Returns a [Bitmask] in which every abstract bit set means the corresponding
+ * metadata in that slot is equal to [m].
+ */
+@PublishedApi
+internal inline fun Group.match(m: Int): Bitmask {
+    // BitmaskLsb * m replicates the byte `m` on every byte of the Long
+    // and XOR-ing with `this` will give us a Long in which every non-zero
+    // byte indicates a match
+    val x = this xor (BitmaskLsb * m)
+    // Turn every non-zero byte into 0x80
+    return (x - BitmaskLsb) and x.inv() and BitmaskMsb
+}
+
+/**
+ * Returns a [Bitmask] in which every abstract bit set indicates an empty slot.
+ */
+private inline fun Group.maskEmpty(): Bitmask {
+    return (this and (this.inv() shl 6)) and BitmaskMsb
+}
+
+/**
+ * Returns a [Bitmask] in which every abstract bit set indicates an empty or deleted slot.
+ */
+@PublishedApi
+internal inline fun Group.maskEmptyOrDeleted(): Bitmask {
+    return (this and (this.inv() shl 7)) and BitmaskMsb
+}
diff --git a/collection/collection/src/commonTest/kotlin/androidx/collection/ScatterMapTest.kt b/collection/collection/src/commonTest/kotlin/androidx/collection/ScatterMapTest.kt
new file mode 100644
index 0000000..ede6765
--- /dev/null
+++ b/collection/collection/src/commonTest/kotlin/androidx/collection/ScatterMapTest.kt
@@ -0,0 +1,1303 @@
+/*
+ * 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.collection
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class ScatterMapTest {
+    @Test
+    fun scatterMap() {
+        val map = MutableScatterMap<String, String>()
+        assertEquals(7, map.capacity)
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun emptyScatterMap() {
+        val map = emptyScatterMap<String, String>()
+        assertEquals(0, map.capacity)
+        assertEquals(0, map.size)
+
+        assertSame(emptyScatterMap<String, String>(), map)
+    }
+
+    @Test
+    fun scatterMapFunction() {
+        val map = mutableScatterMapOf<String, String>()
+        assertEquals(7, map.capacity)
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun zeroCapacityHashMap() {
+        val map = MutableScatterMap<String, String>(0)
+        assertEquals(0, map.capacity)
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun scatterMapWithCapacity() {
+        // When unloading the suggested capacity, we'll fall outside of the
+        // expected bucket of 2047 entries, and we'll get 4095 instead
+        val map = MutableScatterMap<String, String>(1800)
+        assertEquals(4095, map.capacity)
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun scatterMapPairsFunction() {
+        val map = mutableScatterMapOf(
+            "Hello" to "World",
+            "Bonjour" to "Monde"
+        )
+        assertEquals(2, map.size)
+        assertEquals("World", map["Hello"])
+        assertEquals("Monde", map["Bonjour"])
+    }
+
+    @Test
+    fun addToMap() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+
+        assertEquals(1, map.size)
+        assertEquals("World", map["Hello"])
+    }
+
+    @Test
+    fun addToSizedMap() {
+        val map = MutableScatterMap<String, String>(12)
+        map["Hello"] = "World"
+
+        assertEquals(1, map.size)
+        assertEquals("World", map["Hello"])
+    }
+
+    @Test
+    fun addToSmallMap() {
+        val map = MutableScatterMap<String, String>(2)
+        map["Hello"] = "World"
+
+        assertEquals(1, map.size)
+        assertEquals(7, map.capacity)
+        assertEquals("World", map["Hello"])
+    }
+
+    @Test
+    fun addToZeroCapacityMap() {
+        val map = MutableScatterMap<String, String>(0)
+        map["Hello"] = "World"
+
+        assertEquals(1, map.size)
+        assertEquals("World", map["Hello"])
+    }
+
+    @Test
+    fun replaceExistingKey() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Hello"] = "Monde"
+
+        assertEquals(1, map.size)
+        assertEquals("Monde", map["Hello"])
+    }
+
+    @Test
+    fun put() {
+        val map = MutableScatterMap<String, String?>()
+
+        assertNull(map.put("Hello", "World"))
+        assertEquals("World", map.put("Hello", "Monde"))
+        assertNull(map.put("Bonjour", null))
+        assertNull(map.put("Bonjour", "Monde"))
+    }
+
+    @Test
+    fun putAllMap() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        map.putAll(mapOf("Hallo" to "Welt", "Hola" to "Mundo"))
+
+        assertEquals(5, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun putAllArray() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        map.putAll(arrayOf("Hallo" to "Welt", "Hola" to "Mundo"))
+
+        assertEquals(5, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun putAllIterable() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        map.putAll(listOf("Hallo" to "Welt", "Hola" to "Mundo"))
+
+        assertEquals(5, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun putAllSequence() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        map.putAll(listOf("Hallo" to "Welt", "Hola" to "Mundo").asSequence())
+
+        assertEquals(5, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun plus() {
+        val map = MutableScatterMap<String, String>()
+        map += "Hello" to "World"
+
+        assertEquals(1, map.size)
+        assertEquals("World", map["Hello"])
+    }
+
+    @Test
+    fun plusMap() {
+        val map = MutableScatterMap<String, String>()
+        map += mapOf("Hallo" to "Welt", "Hola" to "Mundo")
+
+        assertEquals(2, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun plusArray() {
+        val map = MutableScatterMap<String, String>()
+        map += arrayOf("Hallo" to "Welt", "Hola" to "Mundo")
+
+        assertEquals(2, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun plusIterable() {
+        val map = MutableScatterMap<String, String>()
+        map += listOf("Hallo" to "Welt", "Hola" to "Mundo")
+
+        assertEquals(2, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun plusSequence() {
+        val map = MutableScatterMap<String, String>()
+        map += listOf("Hallo" to "Welt", "Hola" to "Mundo").asSequence()
+
+        assertEquals(2, map.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun nullKey() {
+        val map = MutableScatterMap<String?, String>()
+        map[null] = "World"
+
+        assertEquals(1, map.size)
+        assertEquals("World", map[null])
+    }
+
+    @Test
+    fun nullValue() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = null
+
+        assertEquals(1, map.size)
+        assertNull(map["Hello"])
+    }
+
+    @Test
+    fun findNonExistingKey() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = "World"
+
+        assertNull(map["Bonjour"])
+    }
+
+    @Test
+    fun getOrDefault() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = "World"
+
+        assertEquals("Monde", map.getOrDefault("Bonjour", "Monde"))
+    }
+
+    @Test
+    fun getOrElse() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = "World"
+        map["Bonjour"] = null
+
+        assertEquals("Monde", map.getOrElse("Bonjour") { "Monde" })
+        assertEquals("Welt", map.getOrElse("Hallo") { "Welt" })
+    }
+
+    @Test
+    fun getOrPut() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = "World"
+
+        var counter = 0
+        map.getOrPut("Hello") {
+            counter++
+            "Monde"
+        }
+        assertEquals("World", map["Hello"])
+        assertEquals(0, counter)
+
+        map.getOrPut("Bonjour") {
+            counter++
+            "Monde"
+        }
+        assertEquals("Monde", map["Bonjour"])
+        assertEquals(1, counter)
+
+        map.getOrPut("Bonjour") {
+            counter++
+            "Welt"
+        }
+        assertEquals("Monde", map["Bonjour"])
+        assertEquals(1, counter)
+
+        map.getOrPut("Hallo") {
+            counter++
+            null
+        }
+        assertNull(map["Hallo"])
+        assertEquals(2, counter)
+
+        map.getOrPut("Hallo") {
+            counter++
+            "Welt"
+        }
+        assertEquals("Welt", map["Hallo"])
+        assertEquals(3, counter)
+    }
+
+    @Test
+    fun remove() {
+        val map = MutableScatterMap<String?, String?>()
+        assertNull(map.remove("Hello"))
+
+        map["Hello"] = "World"
+        assertEquals("World", map.remove("Hello"))
+        assertEquals(0, map.size)
+
+        map[null] = "World"
+        assertEquals("World", map.remove(null))
+        assertEquals(0, map.size)
+
+        map["Hello"] = null
+        assertNull(map.remove("Hello"))
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun removeThenAdd() {
+        // Use a size of 6 to fit in a single entry in the metadata table
+        val map = MutableScatterMap<String?, String?>(6)
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        map["Konnichiwa"] = "Sekai"
+        map["Ciao"] = "Mondo"
+        map["Annyeong"] = "Sesang"
+
+        // Removing all the entries will mark the medata as deleted
+        map.remove("Hello")
+        map.remove("Bonjour")
+        map.remove("Hallo")
+        map.remove("Konnichiwa")
+        map.remove("Ciao")
+        map.remove("Annyeong")
+
+        assertEquals(0, map.size)
+
+        val capacity = map.capacity
+
+        // Make sure reinserting an entry after filling the table
+        // with "Deleted" markers works
+        map["Hola"] = "Mundo"
+
+        assertEquals(1, map.size)
+        assertEquals(capacity, map.capacity)
+    }
+
+    @Test
+    fun minus() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        map -= "Hello"
+
+        assertEquals(2, map.size)
+        assertNull(map["Hello"])
+    }
+
+    @Test
+    fun minusArray() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        map -= arrayOf("Hallo", "Bonjour")
+
+        assertEquals(1, map.size)
+        assertNull(map["Hallo"])
+        assertNull(map["Bonjour"])
+    }
+
+    @Test
+    fun minusIterable() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        map -= listOf("Hallo", "Bonjour")
+
+        assertEquals(1, map.size)
+        assertNull(map["Hallo"])
+        assertNull(map["Bonjour"])
+    }
+
+    @Test
+    fun minusSequence() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        map -= listOf("Hallo", "Bonjour").asSequence()
+
+        assertEquals(1, map.size)
+        assertNull(map["Hallo"])
+        assertNull(map["Bonjour"])
+    }
+
+    @Test
+    fun conditionalRemove() {
+        val map = MutableScatterMap<String?, String?>()
+        assertFalse(map.remove("Hello", "World"))
+
+        map["Hello"] = "World"
+        assertTrue(map.remove("Hello", "World"))
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun insertManyEntries() {
+        val map = MutableScatterMap<String, String>()
+
+        for (i in 0 until 1700) {
+            val s = i.toString()
+            map[s] = s
+        }
+
+        assertEquals(1700, map.size)
+    }
+
+    @Test
+    fun forEach() {
+        for (i in 0..48) {
+            val map = MutableScatterMap<String, String>()
+
+            for (j in 0 until i) {
+                val s = j.toString()
+                map[s] = s
+            }
+
+            var counter = 0
+            map.forEach { key, value ->
+                assertEquals(key, value)
+                counter++
+            }
+
+            assertEquals(i, counter)
+        }
+    }
+
+    @Test
+    fun forEachKey() {
+        for (i in 0..48) {
+            val map = MutableScatterMap<String, String>()
+
+            for (j in 0 until i) {
+                val s = j.toString()
+                map[s] = s
+            }
+
+            var counter = 0
+            map.forEachKey { key ->
+                assertNotNull(key.toIntOrNull())
+                counter++
+            }
+
+            assertEquals(i, counter)
+        }
+    }
+
+    @Test
+    fun forEachValue() {
+        for (i in 0..48) {
+            val map = MutableScatterMap<String, String>()
+
+            for (j in 0 until i) {
+                val s = j.toString()
+                map[s] = s
+            }
+
+            var counter = 0
+            map.forEachValue { value ->
+                assertNotNull(value.toIntOrNull())
+                counter++
+            }
+
+            assertEquals(i, counter)
+        }
+    }
+
+    @Test
+    fun clear() {
+        val map = MutableScatterMap<String, String>()
+
+        for (i in 0 until 32) {
+            val s = i.toString()
+            map[s] = s
+        }
+
+        val capacity = map.capacity
+        map.clear()
+
+        assertEquals(0, map.size)
+        assertEquals(capacity, map.capacity)
+    }
+
+    @Test
+    fun string() {
+        val map = MutableScatterMap<String?, String?>()
+        assertEquals("{}", map.toString())
+
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        assertTrue(
+            "{Hello=World, Bonjour=Monde}" == map.toString() ||
+                "{Bonjour=Monde, Hello=World}" == map.toString()
+        )
+
+        map.clear()
+        map["Hello"] = null
+        assertEquals("{Hello=null}", map.toString())
+
+        map.clear()
+        map[null] = "Monde"
+        assertEquals("{null=Monde}", map.toString())
+
+        val selfAsKeyMap = MutableScatterMap<Any, String>()
+        selfAsKeyMap[selfAsKeyMap] = "Hello"
+        assertEquals("{(this)=Hello}", selfAsKeyMap.toString())
+
+        val selfAsValueMap = MutableScatterMap<String, Any>()
+        selfAsValueMap["Hello"] = selfAsValueMap
+        assertEquals("{Hello=(this)}", selfAsValueMap.toString())
+
+        // Test with a small map
+        val map2 = MutableScatterMap<String?, String?>(2)
+        map2["Hello"] = "World"
+        map2["Bonjour"] = "Monde"
+        assertTrue(
+            "{Hello=World, Bonjour=Monde}" == map2.toString() ||
+                "{Bonjour=Monde, Hello=World}" == map2.toString()
+        )
+    }
+
+    @Test
+    fun equals() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertFalse(map.equals(null))
+        assertEquals(map, map)
+
+        val map2 = MutableScatterMap<String?, String?>()
+        map2["Bonjour"] = null
+        map2[null] = "Monde"
+
+        assertNotEquals(map, map2)
+
+        map2["Hello"] = "World"
+        assertEquals(map, map2)
+    }
+
+    @Test
+    fun containsKey() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertTrue(map.containsKey("Hello"))
+        assertTrue(map.containsKey(null))
+        assertFalse(map.containsKey("World"))
+    }
+
+    @Test
+    fun contains() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertTrue("Hello" in map)
+        assertTrue(null in map)
+        assertFalse("World" in map)
+    }
+
+    @Test
+    fun containsValue() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertTrue(map.containsValue("World"))
+        assertTrue(map.containsValue(null))
+        assertFalse(map.containsValue("Hello"))
+    }
+
+    @Test
+    fun empty() {
+        val map = MutableScatterMap<String?, String?>()
+        assertTrue(map.isEmpty())
+        assertFalse(map.isNotEmpty())
+        assertTrue(map.none())
+        assertFalse(map.any())
+
+        map["Hello"] = "World"
+
+        assertFalse(map.isEmpty())
+        assertTrue(map.isNotEmpty())
+        assertTrue(map.any())
+        assertFalse(map.none())
+    }
+
+    @Test
+    fun count() {
+        val map = MutableScatterMap<String, String>()
+        assertEquals(0, map.count())
+
+        map["Hello"] = "World"
+        assertEquals(1, map.count())
+
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        map["Konnichiwa"] = "Sekai"
+        map["Ciao"] = "Mondo"
+        map["Annyeong"] = "Sesang"
+
+        assertEquals(2, map.count { key, _ -> key.startsWith("H") })
+        assertEquals(0, map.count { key, _ -> key.startsWith("W") })
+    }
+
+    @Test
+    fun any() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        map["Konnichiwa"] = "Sekai"
+        map["Ciao"] = "Mondo"
+        map["Annyeong"] = "Sesang"
+
+        assertTrue(map.any { key, _ -> key.startsWith("K") })
+        assertFalse(map.any { key, _ -> key.startsWith("W") })
+    }
+
+    @Test
+    fun all() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        map["Konnichiwa"] = "Sekai"
+        map["Ciao"] = "Mondo"
+        map["Annyeong"] = "Sesang"
+
+        assertTrue(map.any { key, value -> key.length >= 5 && value.length >= 4 })
+        assertFalse(map.all { key, _ -> key.startsWith("W") })
+    }
+
+    @Test
+    fun asMapSize() {
+        val map = MutableScatterMap<String, String>()
+        val asMap = map.asMap()
+        assertEquals(0, asMap.size)
+
+        map["Hello"] = "World"
+        assertEquals(1, asMap.size)
+    }
+
+    @Test
+    fun asMapIsEmpty() {
+        val map = MutableScatterMap<String, String>()
+        val asMap = map.asMap()
+        assertTrue(asMap.isEmpty())
+
+        map["Hello"] = "World"
+        assertFalse(asMap.isEmpty())
+    }
+
+    @Test
+    fun asMapContainsValue() {
+        val map = MutableScatterMap<String, String?>()
+        map["Hello"] = "World"
+        map["Bonjour"] = null
+
+        val asMap = map.asMap()
+        assertTrue(asMap.containsValue("World"))
+        assertTrue(asMap.containsValue(null))
+        assertFalse(asMap.containsValue("Monde"))
+    }
+
+    @Test
+    fun asMapContainsKey() {
+        val map = MutableScatterMap<String?, String>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+
+        val asMap = map.asMap()
+        assertTrue(asMap.containsKey("Hello"))
+        assertTrue(asMap.containsKey(null))
+        assertFalse(asMap.containsKey("Bonjour"))
+    }
+
+    @Test
+    fun asMapGet() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        val asMap = map.asMap()
+        assertEquals("World", asMap["Hello"])
+        assertEquals("Monde", asMap[null])
+        assertEquals(null, asMap["Bonjour"])
+        assertEquals(null, asMap["Hallo"])
+    }
+
+    @Test
+    fun asMapValues() {
+        val map = MutableScatterMap<String?, String?>()
+        val values = map.asMap().values
+        assertTrue(values.isEmpty())
+        assertEquals(0, values.size)
+
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+        map["Hello2"] = "World" // duplicate value
+
+        assertEquals(map.size, values.size)
+        for (value in values) {
+            assertTrue(map.containsValue(value))
+        }
+
+        map.forEachValue { value ->
+            assertTrue(values.contains(value))
+        }
+    }
+
+    @Test
+    fun asMapKeys() {
+        val map = MutableScatterMap<String?, String?>()
+        val keys = map.asMap().keys
+        assertTrue(keys.isEmpty())
+        assertEquals(0, keys.size)
+
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertEquals(map.size, keys.size)
+        for (key in keys) {
+            assertTrue(map.containsKey(key))
+        }
+
+        map.forEachKey { key ->
+            assertTrue(keys.contains(key))
+        }
+    }
+
+    @Test
+    fun asMapEntries() {
+        val map = MutableScatterMap<String?, String?>()
+        val entries = map.asMap().entries
+        assertTrue(entries.isEmpty())
+        assertEquals(0, entries.size)
+
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        assertEquals(map.size, entries.size)
+        for (entry in entries) {
+            assertEquals(map[entry.key], entry.value)
+        }
+
+        map.forEach { key, value ->
+            assertTrue(entries.contains(object : Map.Entry<String?, String?> {
+                override val key: String? get() = key
+                override val value: String? get() = value
+            }))
+        }
+    }
+
+    @Test
+    fun asMutableMapClear() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        val mutableMap = map.asMutableMap()
+        mutableMap.clear()
+
+        assertEquals(0, mutableMap.size)
+        assertEquals(map.size, mutableMap.size)
+        assertTrue(map.isEmpty())
+        assertTrue(mutableMap.isEmpty())
+    }
+
+    @Test
+    fun asMutableMapPut() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        val mutableMap = map.asMutableMap()
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(3, mutableMap.size)
+
+        assertNull(mutableMap.put("Hallo", "Welt"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(4, mutableMap.size)
+
+        assertEquals(null, mutableMap.put("Bonjour", "Monde"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(4, mutableMap.size)
+    }
+
+    @Test
+    fun asMutableMapRemove() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        val mutableMap = map.asMutableMap()
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(3, mutableMap.size)
+
+        assertNull(mutableMap.remove("Hallo"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(3, mutableMap.size)
+
+        assertEquals("World", mutableMap.remove("Hello"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(2, mutableMap.size)
+    }
+
+    @Test
+    fun asMutableMapPutAll() {
+        val map = MutableScatterMap<String?, String?>()
+        map["Hello"] = "World"
+        map[null] = "Monde"
+        map["Bonjour"] = null
+
+        val mutableMap = map.asMutableMap()
+        mutableMap.putAll(mapOf("Hallo" to "Welt", "Hola" to "Mundo"))
+
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(5, mutableMap.size)
+        assertEquals("Welt", map["Hallo"])
+        assertEquals("Mundo", map["Hola"])
+    }
+
+    @Test
+    fun asMutableMapValuesContains() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val values = mutableMap.values
+
+        assertFalse(values.contains("Mundo"))
+        assertTrue(values.contains("Monde"))
+        assertFalse(values.containsAll(listOf("Monde", "Mundo")))
+        assertTrue(values.containsAll(listOf("Monde", "Welt")))
+    }
+
+    @Test
+    fun asMutableMapValuesAdd() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val values = mutableMap.values
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            values.add("XXX")
+        }
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            values.addAll(listOf("XXX"))
+        }
+    }
+
+    @Test
+    fun asMutableMapValuesRemoveRetain() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val values = mutableMap.values
+
+        assertTrue(values.remove("Monde"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(map.size, values.size)
+        assertEquals(2, map.size)
+
+        assertFalse(values.remove("Monde"))
+        assertEquals(2, map.size)
+
+        assertFalse(values.removeAll(listOf("Mundo")))
+        assertEquals(2, map.size)
+
+        assertTrue(values.removeAll(listOf("World", "Welt")))
+        assertEquals(0, map.size)
+
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        assertEquals(3, map.size)
+
+        assertFalse(values.retainAll(listOf("World", "Monde", "Welt")))
+        assertEquals(3, map.size)
+
+        assertTrue(values.retainAll(listOf("World", "Welt")))
+        assertEquals(2, map.size)
+
+        values.clear()
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun asMutableMapValuesIterator() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val values = mutableMap.values
+
+        for (value in values) {
+            assertTrue(map.containsValue(value))
+        }
+
+        val size = map.size
+        assertEquals(3, map.size)
+        // No-op before a call to next()
+        val iterator = values.iterator()
+        iterator.remove()
+        assertEquals(size, map.size)
+
+        assertTrue(iterator.hasNext())
+        assertEquals("World", iterator.next())
+        iterator.remove()
+        assertEquals(2, map.size)
+
+        assertFalse(MutableScatterMap<String, String>().asMutableMap().values.iterator().hasNext())
+    }
+
+    @Test
+    fun asMutableMapKeysContains() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val keys = mutableMap.keys
+
+        assertFalse(keys.contains("Hola"))
+        assertTrue(keys.contains("Bonjour"))
+        assertFalse(keys.containsAll(listOf("Bonjour", "Hola")))
+        assertTrue(keys.containsAll(listOf("Bonjour", "Hallo")))
+    }
+
+    @Test
+    fun asMutableMapKeysAdd() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val keys = mutableMap.keys
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            keys.add("XXX")
+        }
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            keys.addAll(listOf("XXX"))
+        }
+    }
+
+    @Test
+    fun asMutableMapKeysRemoveRetain() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val keys = mutableMap.keys
+
+        assertTrue(keys.remove("Bonjour"))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(map.size, keys.size)
+        assertEquals(2, map.size)
+
+        assertFalse(keys.remove("Bonjour"))
+        assertEquals(2, map.size)
+
+        assertFalse(keys.removeAll(listOf("Hola")))
+        assertEquals(2, map.size)
+
+        assertTrue(keys.removeAll(listOf("Hello", "Hallo")))
+        assertEquals(0, map.size)
+
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        assertEquals(3, map.size)
+
+        assertFalse(keys.retainAll(listOf("Hello", "Bonjour", "Hallo")))
+        assertEquals(3, map.size)
+
+        assertTrue(keys.retainAll(listOf("Hello", "Hallo")))
+        assertEquals(2, map.size)
+
+        keys.clear()
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun asMutableMapKeysIterator() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val keys = mutableMap.keys
+
+        for (key in keys) {
+            assertTrue(key in map)
+        }
+
+        val size = map.size
+        assertEquals(3, map.size)
+        // No-op before a call to next()
+        val iterator = keys.iterator()
+        iterator.remove()
+        assertEquals(size, map.size)
+
+        assertTrue(iterator.hasNext())
+        assertEquals("Hello", iterator.next())
+        iterator.remove()
+        assertEquals(2, map.size)
+
+        assertFalse(MutableScatterMap<String, String>().asMutableMap().keys.iterator().hasNext())
+    }
+
+    class MutableMapEntry(
+        override val key: String,
+        override val value: String
+    ) : MutableMap.MutableEntry<String, String> {
+        override fun setValue(newValue: String): String {
+            throw UnsupportedOperationException()
+        }
+    }
+
+    @Test
+    fun asMutableMapEntriesContains() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val entries = mutableMap.entries
+
+        assertFalse(entries.contains(MutableMapEntry("Hola", "Mundo")))
+        assertTrue(entries.contains(MutableMapEntry("Bonjour", "Monde")))
+        assertFalse(entries.contains(MutableMapEntry("Bonjour", "Le Monde")))
+        assertFalse(
+            entries.containsAll(
+                listOf(
+                    MutableMapEntry("Bonjour", "Monde"),
+                    MutableMapEntry("Hola", "Mundo")
+                )
+            )
+        )
+        assertTrue(
+            entries.containsAll(
+                listOf(
+                    MutableMapEntry("Bonjour", "Monde"),
+                    MutableMapEntry("Hallo", "Welt")
+                )
+            )
+        )
+        assertFalse(
+            entries.containsAll(
+                listOf(
+                    MutableMapEntry("Bonjour", "Le Monde"),
+                    MutableMapEntry("Hallo", "Welt")
+                )
+            )
+        )
+    }
+
+    @Test
+    fun asMutableMapEntriesAdd() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val entries = mutableMap.entries
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            entries.add(MutableMapEntry("X", "XX"))
+        }
+
+        assertFailsWith(UnsupportedOperationException::class) {
+            entries.addAll(listOf(MutableMapEntry("X", "XX")))
+        }
+    }
+
+    @Suppress("ConvertArgumentToSet")
+    @Test
+    fun asMutableMapEntriesRemoveRetain() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val entries = mutableMap.entries
+
+        assertTrue(entries.remove(MutableMapEntry("Bonjour", "Monde")))
+        assertEquals(map.size, mutableMap.size)
+        assertEquals(map.size, entries.size)
+        assertEquals(2, map.size)
+
+        assertFalse(entries.remove(MutableMapEntry("Bonjour", "Monde")))
+        assertEquals(2, map.size)
+
+        assertFalse(entries.remove(MutableMapEntry("Hello", "The World")))
+        assertEquals(2, map.size)
+
+        assertFalse(entries.removeAll(listOf(MutableMapEntry("Hola", "Mundo"))))
+        assertEquals(2, map.size)
+
+        assertTrue(
+            entries.removeAll(
+                listOf(
+                    MutableMapEntry("Hello", "World"),
+                    MutableMapEntry("Hallo", "Welt")
+                )
+            )
+        )
+        assertEquals(0, map.size)
+
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+        assertEquals(3, map.size)
+
+        assertFalse(
+            entries.retainAll(
+                listOf(
+                    MutableMapEntry("Hello", "World"),
+                    MutableMapEntry("Bonjour", "Monde"),
+                    MutableMapEntry("Hallo", "Welt")
+
+                )
+            )
+        )
+        assertEquals(3, map.size)
+
+        assertTrue(
+            entries.retainAll(
+                listOf(
+                    MutableMapEntry("Hello", "World"),
+                    MutableMapEntry("Bonjour", "Le Monde"),
+                    MutableMapEntry("Hallo", "Welt")
+
+                )
+            )
+        )
+        assertEquals(2, map.size)
+
+        assertTrue(
+            entries.retainAll(
+                listOf(
+                    MutableMapEntry("Hello", "World"),
+                )
+            )
+        )
+        assertEquals(1, map.size)
+
+        entries.clear()
+        assertEquals(0, map.size)
+    }
+
+    @Test
+    fun asMutableMapEntriesIterator() {
+        val map = MutableScatterMap<String, String>()
+        map["Hello"] = "World"
+        map["Bonjour"] = "Monde"
+        map["Hallo"] = "Welt"
+
+        val mutableMap = map.asMutableMap()
+        val entries = mutableMap.entries
+
+        for (entry in entries) {
+            assertEquals(entry.value, map[entry.key])
+        }
+
+        val size = map.size
+        assertEquals(3, map.size)
+        // No-op before a call to next()
+        val iterator = entries.iterator()
+        iterator.remove()
+        assertEquals(size, map.size)
+
+        assertTrue(iterator.hasNext())
+        val next = iterator.next()
+        assertEquals("Hello", next.key)
+        assertEquals("World", next.value)
+        iterator.remove()
+        assertEquals(2, map.size)
+
+        map.clear()
+        map["Hello"] = "World"
+        map["Hallo"] = "Welt"
+
+        assertFalse(map.any { _, value -> value == "XX" })
+        for (entry in entries) {
+            assertTrue(entry.setValue("XX").startsWith("W"))
+        }
+        assertTrue(map.all { _, value -> value == "XX" })
+
+        assertFalse(MutableScatterMap<String, String>().asMutableMap().entries.iterator().hasNext())
+    }
+
+    @Test
+    fun trim() {
+        val map = MutableScatterMap<String, String>()
+        assertEquals(7, map.trim())
+
+        map["Hello"] = "World"
+        map["Hallo"] = "Welt"
+
+        assertEquals(0, map.trim())
+
+        for (i in 0 until 1700) {
+            val s = i.toString()
+            map[s] = s
+        }
+
+        assertEquals(2047, map.capacity)
+
+        // After removing these items, our capacity needs should go
+        // from 2047 down to 1023
+        for (i in 0 until 1700) {
+            if (i and 0x1 == 0x0) {
+                val s = i.toString()
+                map.remove(s)
+            }
+        }
+
+        assertEquals(1024, map.trim())
+        assertEquals(0, map.trim())
+    }
+}
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt
index 148eb8b..22289de 100644
--- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithBoxWithConstraints.kt
@@ -49,6 +49,7 @@
 import androidx.compose.ui.layout.LookaheadScope
 import androidx.compose.ui.unit.dp
 
+@Suppress("UnusedBoxWithConstraintsScope")
 @Composable
 fun LookaheadWithBoxWithConstraints() {
     Box(Modifier.fillMaxSize()) {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ClassStabilityTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ClassStabilityTransformTests.kt
index 7183a6a..eaa46aa 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ClassStabilityTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ClassStabilityTransformTests.kt
@@ -1059,7 +1059,7 @@
                   y = null
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(y)
                 A(null, %composer, 0, 0b0001)
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamSignatureTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamSignatureTests.kt
index f476366..6d093cc 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamSignatureTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamSignatureTests.kt
@@ -175,6 +175,23 @@
     )
 
     @Test
+    fun testConstantReturn() = validateBytecode(
+        """
+            @Composable
+            fun Test(): Int {
+                return 123 // line 12
+            }
+        """
+    ) {
+        val lines = it.split("\n").map { it.trim() }
+        val lineNumberIndex = lines.indexOfFirst { it.startsWith("LINENUMBER 12") }
+        // Line 12, which has the return statement, needs to be present in the bytecode
+        assert(lineNumberIndex >= 0)
+        // The return statement should be right after this
+        assert(lines[lineNumberIndex + 1] == "IRETURN")
+    }
+
+    @Test
     fun testForLoopIssue2() = codegen(
         """
             @Composable
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamTransformTests.kt
index 115f7b9..a5542f0 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ComposerParamTransformTests.kt
@@ -311,7 +311,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(values)
                 if (isTraceInProgress()) {
@@ -587,7 +587,7 @@
                   }
                   if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                     if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
+                      traceEventStart(<>, %dirty, -1, <>)
                     }
                     used(text)
                     if (isTraceInProgress()) {
@@ -610,7 +610,7 @@
                   }
                   if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                     if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
+                      traceEventStart(<>, %dirty, -1, <>)
                     }
                     %composer.startMovableGroup(<>, value)
                     sourceInformation(%composer, "<Wrappe...>")
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ContextReceiversTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ContextReceiversTransformTests.kt
index f1ddf88..3878e89 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ContextReceiversTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ContextReceiversTransformTests.kt
@@ -287,7 +287,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 if (isTraceInProgress()) {
                   traceEventEnd()
@@ -371,7 +371,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 with(foo) {
                   A(%this%with, %composer, 0)
@@ -425,7 +425,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 with(foo) {
                   A(%this%with, %composer, 0)
@@ -474,7 +474,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 with(foo) {
                   "Hello".A(%this%with, 2, null, %composer, 0b000110000110, 0b0100)
@@ -658,7 +658,7 @@
                   b = 2
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val combineParams = a + b
                 if (%context_receiver_0.someString == combineParams) {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
index d5dca9d..d428ebd 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
@@ -298,7 +298,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -370,7 +370,7 @@
               }
               if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -447,7 +447,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -565,7 +565,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -625,7 +625,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -690,7 +690,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M1({ %composer: Composer?, %changed: Int ->
@@ -765,7 +765,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 M3({ %composer: Composer?, %changed: Int ->
@@ -835,7 +835,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Text("Root - before", %composer, 0b0110)
                 M1({ %composer: Composer?, %changed: Int ->
@@ -961,7 +961,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 if (condition) {
                   if (isTraceInProgress()) {
@@ -1084,7 +1084,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 IW({ %composer: Composer?, %changed: Int ->
                   sourceInformationMarkerStart(%composer, <>, "C<A()>:Test.kt")
@@ -1131,7 +1131,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Text("Some text", %composer, 0b0110)
                 Identity {
@@ -1185,7 +1185,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Text("Some text", %composer, 0b0110)
                 M1({ %composer: Composer?, %changed: Int ->
@@ -1243,7 +1243,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Text("Some text", %composer, 0b0110)
                 M1({ %composer: Composer?, %changed: Int ->
@@ -1328,7 +1328,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Text("Root - before", %composer, 0b0110)
                 M1({ %composer: Composer?, %changed: Int ->
@@ -3842,7 +3842,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val tmp0_safe_receiver = x
                 %composer.startReplaceableGroup(<>)
@@ -3904,7 +3904,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 x?.let { it: Int ->
                   if (it > 0) {
@@ -4182,7 +4182,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(value)
                 A(%composer, 0)
@@ -4367,7 +4367,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4402,7 +4402,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4437,7 +4437,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4472,7 +4472,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4507,7 +4507,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4542,7 +4542,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4577,7 +4577,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4612,7 +4612,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4647,7 +4647,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4682,7 +4682,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4717,7 +4717,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4752,7 +4752,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4787,7 +4787,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4822,7 +4822,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4857,7 +4857,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4892,7 +4892,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4927,7 +4927,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4962,7 +4962,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -4997,7 +4997,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5032,7 +5032,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5067,7 +5067,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5102,7 +5102,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5137,7 +5137,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5172,7 +5172,7 @@
               }
               if (%dirty and 0b0001011011011011 !== 0b010010010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(p0)
                 used(p1)
@@ -5220,7 +5220,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, "androidx.compose.runtime.tests.Test (Test.kt:6)")
+                  traceEventStart(<>, %dirty, -1, "androidx.compose.runtime.tests.Test (Test.kt:6)")
                 }
                 used(value)
                 if (isTraceInProgress()) {
@@ -5795,7 +5795,7 @@
               }
               if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val a = remember({
                   A()
@@ -5855,7 +5855,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Array(n) { it: Int ->
                   val tmp0_return = remember({
@@ -5977,7 +5977,7 @@
                   keyboardActions2 = false
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 println("t41 insideFunction %isError")
                 println("t41 insideFunction %keyboardActions2")
@@ -6027,7 +6027,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Test(%composer.startReplaceableGroup(<>)
                 sourceInformation(%composer, "<rememb...>")
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/DefaultParamTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/DefaultParamTransformTests.kt
index 29dcd11..47897c2 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/DefaultParamTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/DefaultParamTransformTests.kt
@@ -116,7 +116,7 @@
                   foo = Foo(0)
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(foo)
                 if (isTraceInProgress()) {
@@ -243,7 +243,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(x)
                 if (isTraceInProgress()) {
@@ -302,7 +302,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(a)
                 print(b)
@@ -650,7 +650,7 @@
                   a30 = 0
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, %changed1, <>)
+                  traceEventStart(<>, %dirty, %dirty1, <>)
                 }
                 used(a00)
                 used(a01)
@@ -1037,7 +1037,7 @@
                   a31 = 0
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, %changed1, <>)
+                  traceEventStart(<>, %dirty, %dirty1, <>)
                 }
                 used(a00)
                 used(a01)
@@ -1436,7 +1436,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, %changed1, <>)
+                  traceEventStart(<>, %dirty, %dirty1, <>)
                 }
                 used(a00)
                 used(a01)
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionBodySkippingTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionBodySkippingTransformTests.kt
index e1af9da..7ae8e40 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionBodySkippingTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionBodySkippingTransformTests.kt
@@ -190,7 +190,7 @@
                   overflow = Companion.Clip
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(style)
                 used(onTextLayout)
@@ -239,7 +239,7 @@
                   arrangement = Arrangement.Top
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(arrangement)
                 if (isTraceInProgress()) {
@@ -690,7 +690,7 @@
                   modifier = Companion
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(modifier)
                 if (isTraceInProgress()) {
@@ -741,7 +741,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(a)
                 if (isTraceInProgress()) {
@@ -915,7 +915,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(modifier)
                 used(shape)
@@ -1187,7 +1187,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(values)
                 if (isTraceInProgress()) {
@@ -1233,7 +1233,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print(values)
                 if (isTraceInProgress()) {
@@ -1392,7 +1392,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(a)
                 used(b)
@@ -1490,7 +1490,7 @@
                   }
                   if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                     if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
+                      traceEventStart(<>, %dirty, -1, <>)
                     }
                     used(it)
                     A(x, 0, %composer, 0b1110 and %dirty@Test, 0b0010)
@@ -1704,7 +1704,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(a)
                 used(b)
@@ -1789,7 +1789,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 print("Hello World")
                 if (isTraceInProgress()) {
@@ -1942,7 +1942,7 @@
                   color = Companion.Unset
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(text)
                 used(color)
@@ -2699,7 +2699,7 @@
                 }
                 if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                   if (isTraceInProgress()) {
-                    traceEventStart(<>, %changed, -1, <>)
+                    traceEventStart(<>, %dirty, -1, <>)
                   }
                   used(${if (useFir) "x" else "<this>.x"})
                   if (isTraceInProgress()) {
@@ -2731,7 +2731,7 @@
                 }
                 if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                   if (isTraceInProgress()) {
-                    traceEventStart(<>, %changed, -1, <>)
+                    traceEventStart(<>, %dirty, -1, <>)
                   }
                   used(${if (useFir) "x" else "<this>.x"})
                   if (isTraceInProgress()) {
@@ -3615,7 +3615,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 %composer.startReplaceableGroup(<>)
                 sourceInformation(%composer, "<A()>")
@@ -3688,7 +3688,7 @@
               }
               if (%dirty and 0b01010001 !== 0b00010000 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(b)
                 if (isTraceInProgress()) {
@@ -3711,7 +3711,7 @@
               }
               if (%dirty and 0b001010000001 !== 0b10000000 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(c)
                 if (isTraceInProgress()) {
@@ -3774,7 +3774,7 @@
               }
               if (%dirty and 0b01011011 !== 0b00010010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(<this>)
                 used(x)
@@ -3801,7 +3801,7 @@
                 }
                 if (%dirty and 0b001011011011 !== 0b10010010 || !%composer.skipping) {
                   if (isTraceInProgress()) {
-                    traceEventStart(<>, %changed, -1, <>)
+                    traceEventStart(<>, %dirty, -1, <>)
                   }
                   used(<this>)
                   used(it)
@@ -3867,7 +3867,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 state.value
                 if (isTraceInProgress()) {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionalInterfaceTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionalInterfaceTransformTests.kt
index 98786eb..dc95588 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionalInterfaceTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/FunctionalInterfaceTransformTests.kt
@@ -58,7 +58,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 with(content) {
                   %this%with.Content(%composer, 0b0110)
@@ -92,7 +92,7 @@
                     }
                     if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                       if (isTraceInProgress()) {
-                        traceEventStart(<>, %changed, -1, <>)
+                        traceEventStart(<>, %dirty, -1, <>)
                       }
                       %this%Test.length
                       if (isTraceInProgress()) {
@@ -150,7 +150,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 Example(A { it: Int ->
                   a.compute(it)
@@ -258,7 +258,7 @@
                   }
                   if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                     if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
+                      traceEventStart(<>, %dirty, -1, <>)
                     }
                     println(string)
                     if (isTraceInProgress()) {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
index 12c05f9..e3f3794 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
@@ -755,7 +755,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val content = composableLambda(%composer, <>, true) { %composer: Composer?, %changed: Int ->
                   sourceInformation(%composer, "C<Displa...>:Test.kt")
@@ -820,7 +820,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 content()
                 if (isTraceInProgress()) {
@@ -886,7 +886,7 @@
           }
           if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
             if (isTraceInProgress()) {
-              traceEventStart(<>, %changed, -1, <>)
+              traceEventStart(<>, %dirty, -1, <>)
             }
             content()
             if (isTraceInProgress()) {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralTransformTests.kt
index e729daf..a78349b 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralTransformTests.kt
@@ -733,7 +733,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 println("%{LiveLiterals%TestKt.String%0%str%arg-0%call-println%fun-UiTextField()}%isError")
                 println("%{LiveLiterals%TestKt.String%0%str%arg-0%call-println-1%fun-UiTextField()}%keyboardActions2")
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralV2TransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralV2TransformTests.kt
index d124150..a850f0b 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralV2TransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/LiveLiteralV2TransformTests.kt
@@ -737,7 +737,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 println("%{LiveLiterals%TestKt.String%0%str%arg-0%call-println%fun-UiTextField()}%isError")
                 println("%{LiveLiterals%TestKt.String%0%str%arg-0%call-println-1%fun-UiTextField()}%keyboardActions2")
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
index 790002d..606659a 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
@@ -623,7 +623,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 if (condition) {
@@ -670,7 +670,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 if (condition) {
                   A(%composer, 0)
@@ -1259,7 +1259,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val foo = remember({
                   Foo()
@@ -1306,7 +1306,7 @@
                   }
                 }
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(a)
                 if (isTraceInProgress()) {
@@ -1380,7 +1380,7 @@
                 }
                 %composer.endDefaults()
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 used(a)
                 used(b)
@@ -1654,7 +1654,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val value = %composer.cache(false) {
                   mutableStateOf(
@@ -1732,7 +1732,7 @@
               }
               if (%dirty and 0b0001 !== 0 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 val show = remember({
                   mutableStateOf(
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TargetAnnotationsTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TargetAnnotationsTransformTests.kt
index 3817942..702c1f3 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TargetAnnotationsTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TargetAnnotationsTransformTests.kt
@@ -535,7 +535,7 @@
           }
           if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
             if (isTraceInProgress()) {
-              traceEventStart(<>, %changed, -1, <>)
+              traceEventStart(<>, %dirty, -1, <>)
             }
             val tmp0_safe_receiver = content
             val tmp1_group = when {
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TraceInformationTest.kt b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TraceInformationTest.kt
index 2321f8c..2aa05c6 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TraceInformationTest.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/test/java/androidx/compose/compiler/plugins/kotlin/TraceInformationTest.kt
@@ -171,7 +171,7 @@
               }
               if (%dirty and 0b1011 !== 0b0010 || !%composer.skipping) {
                 if (isTraceInProgress()) {
-                  traceEventStart(<>, %changed, -1, <>)
+                  traceEventStart(<>, %dirty, -1, <>)
                 }
                 A(%composer, 0)
                 Wrapper({ %composer: Composer?, %changed: Int ->
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
index 066e0d3..db7ddf7 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
@@ -542,6 +542,19 @@
         )
     }
 
+    protected fun irReturnVar(
+        target: IrReturnTargetSymbol,
+        value: IrVariable,
+    ): IrExpression {
+        return IrReturnImpl(
+            value.initializer?.startOffset ?: UNDEFINED_OFFSET,
+            value.initializer?.endOffset ?: UNDEFINED_OFFSET,
+            value.type,
+            target,
+            irGet(value)
+        )
+    }
+
     protected fun irEqual(lhs: IrExpression, rhs: IrExpression): IrExpression {
         return irCall(
             this.context.irBuiltIns.eqeqeqSymbol,
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
index 07c13e3..b1b54c0 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
@@ -872,9 +872,6 @@
 
         transformed = transformed.apply {
             transformChildrenVoid()
-            if (emitTraceMarkers) {
-                wrapWithTraceEvents(irFunctionSourceKey(), scope)
-            }
         }
 
         buildPreambleStatementsAndReturnIfSkippingPossible(
@@ -889,6 +886,12 @@
             defaultScope,
         )
 
+        // NOTE: It's important to do this _after_ the above call since it can change the
+        // value of `dirty.used`.
+        if (emitTraceMarkers) {
+            transformed.wrapWithTraceEvents(irFunctionSourceKey(), scope)
+        }
+
         if (!elideGroups) {
             scope.realizeGroup {
                 irComposite(statements = listOfNotNull(
@@ -926,7 +929,7 @@
                         irSourceInformationMarkerEnd(body, scope)
                     else -> null
                 },
-                returnVar?.let { irReturn(declaration.symbol, irGet(it)) }
+                returnVar?.let { irReturnVar(declaration.symbol, it) }
             )
         )
         if (elideGroups && !hasExplicitGroups) {
@@ -1025,9 +1028,6 @@
         // are using the dispatchReceiverParameter or the extensionReceiverParameter
         val transformed = nonReturningBody.apply {
             transformChildrenVoid()
-            if (emitTraceMarkers) {
-                wrapWithTraceEvents(irFunctionSourceKey(), scope)
-            }
         }
         canSkipExecution = buildPreambleStatementsAndReturnIfSkippingPossible(
             body,
@@ -1041,6 +1041,12 @@
             Scope.ParametersScope(),
         )
 
+        // NOTE: It's important to do this _after_ the above call since it can change the
+        // value of `dirty.used`.
+        if (emitTraceMarkers) {
+            transformed.wrapWithTraceEvents(irFunctionSourceKey(), scope)
+        }
+
         val dirtyForSkipping = if (dirty.used && dirty is IrChangedBitMaskVariable) {
             skipPreamble.statements.addAll(0, dirty.asStatements())
             dirty
@@ -1090,7 +1096,7 @@
                     *skipPreamble.statements.toTypedArray(),
                     *bodyPreamble.statements.toTypedArray(),
                     transformedBody,
-                    returnVar?.let { irReturn(declaration.symbol, irGet(it)) }
+                    returnVar?.let { irReturnVar(declaration.symbol, it) }
                 )
             )
         } else {
@@ -1105,7 +1111,7 @@
                     *bodyPreamble.statements.toTypedArray(),
                     transformed,
                     *bodyEpilogue.statements.toTypedArray(),
-                    returnVar?.let { irReturn(declaration.symbol, irGet(it)) }
+                    returnVar?.let { irReturnVar(declaration.symbol, it) }
                 )
             )
         }
@@ -1171,7 +1177,7 @@
 
         val endWithTraceEventEnd = {
             irComposite(statements = listOfNotNull(
-                irTraceEventEnd(),
+                if (traceEventMarkersEnabled) irTraceEventEnd() else null,
                 end()
             ))
         }
@@ -1187,10 +1193,6 @@
         // are using the dispatchReceiverParameter or the extensionReceiverParameter
         val transformed = nonReturningBody.apply {
             transformChildrenVoid()
-            wrapWithTraceEvents(
-                irFunctionSourceKey(),
-                scope,
-            )
         }
 
         val canSkipExecution = buildPreambleStatementsAndReturnIfSkippingPossible(
@@ -1206,6 +1208,12 @@
             defaultScope,
         )
 
+        // NOTE: It's important to do this _after_ the above call since it can change the
+        // value of `dirty.used`.
+        if (traceEventMarkersEnabled) {
+            transformed.wrapWithTraceEvents(irFunctionSourceKey(), scope)
+        }
+
         // if it has non-optional unstable params, the function can never skip, so we always
         // execute the body. Otherwise, we wrap the body in an if and only skip when certain
         // conditions are met.
@@ -1279,7 +1287,7 @@
                 *skipPreamble.statements.toTypedArray(),
                 transformedBody,
                 if (returnVar == null) end() else null,
-                returnVar?.let { irReturn(declaration.symbol, irGet(it)) }
+                returnVar?.let { irReturnVar(declaration.symbol, it) }
             )
         )
 
@@ -2398,10 +2406,12 @@
         after: List<IrExpression> = emptyList()
     ): IrExpression {
         return if (after.isEmpty() || type.isNothing() || type.isUnit()) {
-            wrap(type, before, after)
+            wrap(startOffset, endOffset, type, before, after)
         } else {
             val tmpVar = irTemporary(this, nameHint = "group")
             tmpVar.wrap(
+                startOffset,
+                endOffset,
                 type,
                 before,
                 after + irGet(tmpVar)
@@ -2410,6 +2420,8 @@
     }
 
     private fun IrStatement.wrap(
+        startOffset: Int,
+        endOffset: Int,
         type: IrType,
         before: List<IrExpression> = emptyList(),
         after: List<IrExpression> = emptyList()
@@ -3460,6 +3472,8 @@
         } else {
             val tempVar = irTemporary(expression.value, nameHint = "return")
             tempVar.wrap(
+                expression.startOffset,
+                expression.endOffset,
                 expression.type,
                 after = listOf(
                     endBlock,
diff --git a/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/BoxWithConstraintsDetector.kt b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/BoxWithConstraintsDetector.kt
new file mode 100644
index 0000000..9d45624
--- /dev/null
+++ b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/BoxWithConstraintsDetector.kt
@@ -0,0 +1,147 @@
+/*
+ * 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.lint
+
+import androidx.compose.lint.inheritsFrom
+import androidx.compose.lint.isInPackageName
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.computeKotlinArgumentMapping
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.PsiWildcardType
+import com.intellij.psi.impl.source.PsiClassReferenceType
+import java.util.EnumSet
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.ULambdaExpression
+import org.jetbrains.uast.USimpleNameReferenceExpression
+import org.jetbrains.uast.UThisExpression
+import org.jetbrains.uast.tryResolve
+import org.jetbrains.uast.visitor.AbstractUastVisitor
+
+class BoxWithConstraintsDetector : Detector(), SourceCodeScanner {
+    override fun getApplicableMethodNames(): List<String> = listOf(
+        FoundationNames.Layout.BoxWithConstraints.shortName
+    )
+
+    override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
+        if (method.isInPackageName(FoundationNames.Layout.PackageName)) {
+            val contentArgument = computeKotlinArgumentMapping(node, method)
+                .orEmpty()
+                .filter { (_, parameter) ->
+                    parameter.name == "content"
+                }
+                .keys
+                .filterIsInstance<ULambdaExpression>()
+                .firstOrNull() ?: return
+
+            var foundValidReference = false
+            contentArgument.accept(object : AbstractUastVisitor() {
+                // Check for references to any property of BoxWithConstraintsScope
+                override fun visitSimpleNameReferenceExpression(
+                    node: USimpleNameReferenceExpression
+                ): Boolean {
+                    val reference = (node.tryResolve() as? PsiMethod)
+                        ?: return foundValidReference // No need to continue if already found
+                    if (reference.isInPackageName(FoundationNames.Layout.PackageName) &&
+                        reference.containingClass?.name == FoundationNames
+                            .Layout.BoxWithConstraintsScope.shortName
+                    ) {
+                        foundValidReference = true
+                    }
+
+                    // Check if reference is an extension property on BoxWithConstraintsScope
+                    if (reference.hierarchicalMethodSignature
+                            .parameterTypes.firstOrNull()?.inheritsFrom(
+                                FoundationNames
+                                    .Layout.BoxWithConstraintsScope
+                            ) == true
+                    ) {
+                        foundValidReference = true
+                    }
+                    return foundValidReference
+                }
+
+                // If this is referenced in the content lambda then consider
+                // the constraints used.
+                override fun visitThisExpression(node: UThisExpression): Boolean {
+                    foundValidReference = true
+                    return foundValidReference
+                }
+
+                // Check function calls inside the content lambda to see if they
+                // are using BoxWithConstraintsScope
+                override fun visitCallExpression(node: UCallExpression): Boolean {
+                    val receiverType = node.receiverType ?: return foundValidReference
+
+                    // Check for function calls with a BoxWithConstraintsScope receiver type
+                    if (receiverType.inheritsFrom(FoundationNames.Layout.BoxWithConstraintsScope)) {
+                        foundValidReference = true
+                        return foundValidReference
+                    }
+
+                    // Check for calls to a lambda with a BoxWithConstraintsScope receiver type
+                    // e.g. BoxWithConstraintsScope.() -> Unit
+                    val firstChildReceiverType = (receiverType as? PsiClassReferenceType)?.reference
+                        ?.typeParameters
+                        ?.firstOrNull() ?: return foundValidReference
+
+                    val resolvedWildcardType = (firstChildReceiverType as? PsiWildcardType)?.bound
+                    if (
+                        resolvedWildcardType?.inheritsFrom(
+                            FoundationNames.Layout.BoxWithConstraintsScope
+                        ) == true
+                    ) {
+                        foundValidReference = true
+                    }
+
+                    return foundValidReference
+                }
+            })
+            if (!foundValidReference) {
+                context.report(
+                    UnusedConstraintsParameter,
+                    node,
+                    context.getLocation(contentArgument),
+                    "BoxWithConstraints scope is not used"
+                )
+            }
+        }
+    }
+
+    companion object {
+        val UnusedConstraintsParameter = Issue.create(
+            "UnusedBoxWithConstraintsScope",
+            "BoxWithConstraints content should use the constraints provided " +
+                "via BoxWithConstraintsScope",
+            "The `content` lambda in BoxWithConstraints has a scope " +
+                "which will include the incoming constraints. If this " +
+                "scope is ignored, then the cost of subcomposition is being wasted and " +
+                "this BoxWithConstraints should be replaced with a Box.",
+            Category.CORRECTNESS, 3, Severity.ERROR,
+            Implementation(
+                BoxWithConstraintsDetector::class.java,
+                EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES)
+            )
+        )
+    }
+}
diff --git a/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationIssueRegistry.kt b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationIssueRegistry.kt
index 2a936c1..d3de50b 100644
--- a/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationIssueRegistry.kt
+++ b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationIssueRegistry.kt
@@ -32,7 +32,8 @@
     override val issues get() = listOf(
         LazyLayoutStateReadInCompositionDetector.FrequentlyChangedStateReadInComposition,
         UnrememberedMutableInteractionSourceDetector.UnrememberedMutableInteractionSource,
-        NonLambdaOffsetModifierDetector.UseOfNonLambdaOverload
+        NonLambdaOffsetModifierDetector.UseOfNonLambdaOverload,
+        BoxWithConstraintsDetector.UnusedConstraintsParameter
     )
     override val vendor = Vendor(
         vendorName = "Jetpack Compose",
diff --git a/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationNames.kt b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationNames.kt
index b6a07ab..c87171b 100644
--- a/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationNames.kt
+++ b/compose/foundation/foundation-lint/src/main/java/androidx/compose/foundation/lint/FoundationNames.kt
@@ -41,5 +41,7 @@
         val PackageName = Package(FoundationNames.PackageName, "layout")
         val Offset = Name(PackageName, "offset")
         val AbsoluteOffset = Name(PackageName, "absoluteOffset")
+        val BoxWithConstraints = Name(PackageName, "BoxWithConstraints")
+        val BoxWithConstraintsScope = Name(PackageName, "BoxWithConstraintsScope")
     }
 }
diff --git a/compose/foundation/foundation-lint/src/test/java/androidx/compose/foundation/lint/BoxWithConstraintsDetectorTest.kt b/compose/foundation/foundation-lint/src/test/java/androidx/compose/foundation/lint/BoxWithConstraintsDetectorTest.kt
new file mode 100644
index 0000000..8052414
--- /dev/null
+++ b/compose/foundation/foundation-lint/src/test/java/androidx/compose/foundation/lint/BoxWithConstraintsDetectorTest.kt
@@ -0,0 +1,406 @@
+/*
+ * 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.lint
+
+import androidx.compose.lint.test.Stubs
+import androidx.compose.lint.test.bytecodeStub
+import androidx.compose.lint.test.kotlinAndBytecodeStub
+import com.android.tools.lint.checks.infrastructure.LintDetectorTest
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Issue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private val ExternalModuleFunctionStub = kotlinAndBytecodeStub(
+    filename = "Other.kt",
+    filepath = "bar/compose",
+    checksum = 0xad5be2a5,
+    source = """
+        package bar.compose
+
+        import androidx.compose.foundation.layout.BoxWithConstraints
+        import androidx.compose.foundation.layout.BoxWithConstraintsScope
+        import androidx.compose.runtime.Composable
+
+        @Composable
+        fun BoxWithConstraintsScope.Other() {}
+
+        @Composable
+        fun UseThis(scope: BoxWithConstraintsScope) {}
+
+        @Composable
+        fun Test() {
+            BoxWithConstraints {
+                UseThis(scope = this)
+            }
+        }
+    """.trimIndent(),
+    """
+                META-INF/main.kotlin_module:
+                H4sIAAAAAAAA/2NgYGBmYGBgBGJ2KM3AZcWllJiXUpSfmVKhl5yfW5BfnKqX
+                ll+al5JYkpmfp5eTWJlfWiIk4pRfEZ5ZkuGcn1dcUpSYmVdS7F3CJcbFnZRY
+                BNMmxO5fkpFaBBTn5WJOy88XYgtJLS7xLlFi0GIAALeBd4B7AAAA
+                """,
+    """
+                bar/compose/OtherKt$Test$1.class:
+                H4sIAAAAAAAA/6VU33MTVRT+7ibNJtvFlCLQFgSVCGmrbIv4i8RiCa2sxOCY
+                Usfp083m0myzubezezdT3vrm/+FfIDojjM44HR/9oxzP3cYQhuKDPOTck3PP
+                +c53zv2Sv/7+7Q8AN3GfYaHDYy9Qg32VCO+B7on4vq5siURXVm0whh+afaWj
+                UHp7w4EXSi1iySOvyQedLq9N3j1KZaBDJRNvc+St1ptcdmMVdg/GHR6pVHa5
+                ufUi/lil2rujDr4Lda9BlTrm1CFpB2pfjLEfylDX1mrE9NVEbOQZLv03GRsF
+                hkI9JLg1hlx1cZshX/UXt10U4TiYwjQFdC9MGC42X70VYlII5VD1BcPt6utM
+                aBhcaap419sTumNuEo9LqTQ/pt5SupVGETWcqxhelZeBiph9cfDxYnypYwIM
+                g8TGmwxng54I+iPEb3jMB4ISGa5Vm3t8yImq3PUedPZEoGsTkbYB2a2ZJZ3D
+                eQdnMcdw5oTl2FhgsB8mYouIuriIGQcX8BbDa2mA4eoJ/BZfDjHc+v9tbLzj
+                omwYW7jCMD0hPBvvMRT9VntrvdXYYDj1gipdXEO1hKtYZLD2VxlmT2JWrAdR
+                pjojtJJpct0UvlEib4Xh9L+QXwvNiS+nEmswzNEPlBljM7C+cXIUPwiNR1VW
+                l9pVjg5d5+jQsWYsx5qzyJ05OlywVtiStWLdc/78sWAVTVX3Bk1V51LJxwOV
+                JiR/kNSNnF3cRokoZq94va8p3FBdYSZRAY+2eRzyTiS2jGEoN0MpWumgI+JR
+                pPJtKnU4EL4chklIobG01p8LmcH1pRRxI+JJIuhreUMGkUpIWTRzT3UZSu1w
+                V3KdxoTptFUaB2IzNA3mRw22j+EnULFC+5uiQehvCvNmobSZPH1oyRS5Q16F
+                MmhWFJbyT+E+MRtFg6x7HMWprOa0eXvKNBUNOi06p5dnzzzD/PIzXDJlFu6S
+                NU9XoFSHSgzMuePUEYzxZnGZoDdGr0ZpmFkn9LdHfL4YobtLy0d491dUfsbS
+                T2P4QsaqPAHtjqFdLON9ui/ig/F057Mcavs7rO+fwvsFq0+ywBQ2M7ZslDCH
+                L7PVXCAC97J2OfjZuY6v6PyEMm9Q1Yc7yPm46eMjsvjYp4tPfXyGWztgCWqo
+                7yCf4PMEawkuJyj/AyfuA+BHBgAA
+                """,
+    """
+                bar/compose/OtherKt.class:
+                H4sIAAAAAAAA/6VUWVMTQRD+ZhNICEGWcMglHgQJqCzggRo8MCXlljFYglgl
+                T5PNAAubWWpnQuEbf0mfKB8snv1Rlj1LRDnUKk1Vpo/p/rr7m06+fvv8BcAd
+                FBm6qzxyvLC+EyrhLOlNEb3UKTAGe4vvcifgcsNZqm4Jj7wJhpY4hOFJocxl
+                LQr92t5x9nrYkDWu/VBS2oewoZ1n4d47X2+WQql0xH2p1bIX7ojixCrD2FmA
+                qCG1XxdOKbZ5NRDU4Gg5jDacLaGrBkE5XMpQx1WUUwl1pREEFNU6rzd99TiN
+                NoaR7VAHvnS2dusO1RSR5IHjSh1Ruu+pFNoZer1N4W0381/ziNeFNnONF8qn
+                5y7+4lk2IBvUfxYduJBBFp0M7XlTO99kZv5/iGFIvVViheCIamVcafQwJFeE
+                0gyJgiFu8JwXy5uA/EwK/Qxpt7K8slApPWcYLv8+tpjFIIbaMIDhk5StN6R3
+                RO9iUyPcEYa5f5rLbNMVavrsTb4m1nkjoLnmCu/Lf26g6J59FvMI1zCawVXk
+                Gbp+ILwSmlNTnLi06rsJ2nNmjhQD2zaKRf4932jTpNVmGJ4e7tuZw/2MZVsZ
+                K03ffovMwbRNhzXNXqQGbdsy2myrnSCZJE/WbjGeydgyOLOMCiEdczy1TUMl
+                S2FNMHSWfSkqjXpVRCtmoxly5dDjwSqPfGM3nUNvjnbflbu+8sm18HPNGfKn
+                b4839kRY1pVSRKWAKyXIzCyHjcgTi74pMNCEWD0DjxlYSMJ8ErQNLWgleZss
+                42eGuslc5gD2R0Me/WmAAugHhzTukp49CkEXciTvNW9TJOearFMgCLf7PNze
+                c3DbT+D2/A23Dxcp3eBONKfoSHzCpUNcTrIDjBl0FqNnKAzoJOTcCbwE7se3
+                xFGc3o8HcUezeEjyEfmvEynja0i4KLiYoBOTLm7gpotbmFoDU3AwvYZWhT5i
+                U6FboUchp9DyHfyqvvRqBQAA
+                """
+)
+
+@RunWith(JUnit4::class)
+class BoxWithConstraintsDetectorTest : LintDetectorTest() {
+    override fun getDetector(): Detector = BoxWithConstraintsDetector()
+
+    override fun getIssues(): MutableList<Issue> =
+        mutableListOf(BoxWithConstraintsDetector.UnusedConstraintsParameter)
+
+    private val BoxWithConstraintsStub = bytecodeStub(
+        filename = "BoxWithConstraints.kt",
+        filepath = "androidx/compose/foundation/layout",
+        checksum = 0xddc1f733,
+        source =
+        """
+            package androidx.compose.foundation.layout
+
+            import androidx.compose.runtime.Composable
+
+            interface Constraints {
+                val minWidth: Int
+            }
+            interface Dp {}
+            interface BoxWithConstraintsScope {
+                val constraints: Constraints
+                val minWidth: Dp
+                val maxWidth: Dp
+                val minHeight: Dp
+                val maxHeight: Dp
+            }
+
+            @Composable
+            fun BoxWithConstraints(
+                propagateMinConstraints: Boolean = false,
+                content: @Composable BoxWithConstraintsScope.() -> Unit
+            ) {}
+        """.trimIndent(),
+        """
+                META-INF/main.kotlin_module:
+                H4sIAAAAAAAA/2NgYGBmYGBgBGJ2KM3AZcWllJiXUpSfmVKhl5yfW5BfnKqX
+                ll+al5JYkpmfp5eTWJlfWiIk4pRfEZ5ZkuGcn1dcUpSYmVdS7F3CxcvFnJaf
+                L8QWklpc4l2ixKDFAAD5174zYwAAAA==
+                """,
+        """
+                androidx/compose/foundation/layout/BoxWithConstraintsKt.class:
+                H4sIAAAAAAAA/6VTXU8TQRQ9sy394qsU+SqIKCAgwhZiwkOJiRKJjQWNRYzw
+                NGyHsrSdaXZnCbwY/oav/gPfiA+G+OiPMt7ZtlrsA4lusnfunTl77p17z/74
+                +fUbgCdYZ9jgsuwpt3xuO6reUL6wj1Ugy1y7Sto1fqECbT9X5+9dfbKlpK89
+                7krtv9JxMIb0KT/jhJIV+/XRqXBoN8KQ6cYzzC0eFKtK11xpn57V7eNAOiaF
+                b2+3vLX80j5D41bY5krxn0ouOaoh8m3yd9LV+adhyvluPi+Q2q0LeyuM+VFN
+                5Blmi8qr2KdCHxlC3+ZSKs2b1e0qvRvUaoSKO0pqIXUCKYbpjqtQDcKTvGYX
+                pPboe9fx4+hjGHFOhFNtEbzhHq8LAjIsLBb/7m6+Y6dkSCp0gT4MYDCFfqQZ
+                xhqeavAK12LHlTfazw4YZm4bAEO2u29zZXHMg5omqdw+wkJ3zabCHsRSsDDG
+                MNRm2BGa08g4JbXqZxGSIzMmTqVWjWPR/rlrvBx55TWGD9eX06nry5SVtppL
+                b7iMW51vNpe+vsxaObaeSBCQvMj6VDqaHc9EM1YuFlqW6/n+OWYl4qFNvIyb
+                BPQrgKTbLq+zKZv/IzgSQZvzxTkJw6dv2uR7FyFgpPvb1Sr1O7qlyoJhsOhK
+                sRvUj4S3Z6RoqlQOr+1zzzVxazNZciuS68Ajf/JtU8AFeeb6Lh0/+6NV+hX/
+                Pv2tuhuw/pLmTnWHN1oJUiUVeI7Ydk0w0eLY7+LHGk06CvNYmDCjp2iJojzF
+                lhnxcqb3CkNfQsAjsjFqfAwjWCZ/tAlBBsMhRRwp3KHzxyE6jpUWPkHrKr1J
+                A6fLA+kkUYySb3JttGoYmIp+/ISeSD67fIXxZkqbbAQsEeYegJFYhjiHiTND
+                x7kQtEjXALaJzlwhe4hIAZMFTJHF3QKmca+AGdw/BPPxALOHSPro8THnIxPa
+                lI95Hw99JHws/AJecgBzcAUAAA==
+                """,
+        """
+                androidx/compose/foundation/layout/BoxWithConstraintsScope.class:
+                H4sIAAAAAAAA/5WSzW7aQBDH/2vA2IYQJ21aQvqdSm0uNUU9tb30Q1WRSCol
+                UhOJkzEOLNi7iF0QvfEUfYAe+hA9VCjHPlTVMSUBQSpRazWz89N/dtYz++v3
+                j58AXuAxw0tfNPuSN0deIOOeVKF3Lgei6WsuhRf5X+RAe2/l6JTr9jsplO77
+                XGh1EshemAVjcDv+0CehaHmfGp0w0FmkGAqtUC/IGSpPD2prVFrIecWwX5P9
+                ltcJdSNByvOFkHqqV96R1EeDKCJVjmodcnHKm7rNcLBeofe9y0x/NMvM/z3n
+                Y8hbbT0L/dFluFXrSh1x4R2G2qezfMo34mGK+sgSk2VgXUIjnkRl2jWfM3yd
+                jEuOUTQcw52MHVrTvZWaecs6L07GFaPMjrddo2SUU2cX39MX30yzlLbSboao
+                STS7QC3XJuos0dyU5pfoxpQWluimaye3qzC8XqdT/xg+/T6og8HikMv/P2I7
+                nrf4ydqDs+KrqdnxfGRWfPUMdlav/axLkr3jgdA8DqtiyBVvROGb+ZticE7k
+                oB+EH3gUMuzOpJ9XhCb1D2kkXybNkIFJrXhIUeKzADEL9gpzrmG5a1h+mVG1
+                R1P7APvk60Q3qGqhjlQVm1W4ZLGVmO0qbuAmCRR2cKsOV+G2QlFhV6GkkFEw
+                FfYU7ijkFWyFuwqOwj2FnMJ9BesPb1xrKxsEAAA=
+                """,
+        """
+                androidx/compose/foundation/layout/Constraints.class:
+                H4sIAAAAAAAA/5WPz07CQBDGv9mWUot/CooiT6AXWonxYjyoiQkJxAQTMeFU
+                aIEVumvYheCNZ/HgQ3gwhKMPZdxyMF7dbH47szO73zdf3x+fAM5xRKhFIp5K
+                Hi+CvkxfpEqCgZyJONJcimASvcqZDm6lUHoacaFVHkTwn6N5ZIpiGNz3npO+
+                zsMiFIaJbnHR4bEeEayT0wah2BxLPeEiaCU6Mp9GlwSWzi2jThnyBBqbqwXP
+                stBE8RnhYrUseazCPOavlp7ZzHc95jJ3UFkt6yykdslnVRZaT+t3e/3mOFXb
+                tf1c9rpOCJv/G8lYAsFNf62Xb+Siw/XoT09trAneg5xN+8kdnySE4/ZMaJ4m
+                j1zx3iS5FkLqjYJyjA/YyBbZhBwcEzGUNzzAoTmvjGDeVNwurAa2GvAMUciw
+                3cAOdrsghT34XTgKRYWSwv6GOQXnB5/tt/C/AQAA
+                """,
+        """
+                androidx/compose/foundation/layout/Dp.class:
+                H4sIAAAAAAAA/41Oy07DQAwcb6Ep4ZXykMoHIG6krXrjxENIlYqQQIJDT9tm
+                C9sku1V2U4Vbv4sD6pmPQjjlB7Cl8diWZ/z98/kFYIATwrk0SWF1UsVTmy+s
+                U/HMliaRXlsTZ/LDlj6+WwQgQjSXS8kz8xY/TuZq6gM0CO1Ran2mTfygvOQ7
+                eUUQ+bLBBlRDQKCUR5Wuuy6zpEc4Xa9aoeiIUETMZp31qi+6VC/7hIvRv55i
+                I7DSja1etX+/tcb5Qmrj3WXqCeGzLYuputeZIpw9lcbrXL1opyeZujbG+o2a
+                a7IntvAXAkcbbOOYa4/VtzmbYzSGCIZoMWKnhnCIXeyNQQ77OBhDOBw6RL/m
+                nxtjWQEAAA==
+                """
+    )
+
+    @Test
+    fun unreferencedConstraints() {
+        lint().files(
+            kotlin(
+                """
+                package foo
+
+                import androidx.compose.foundation.layout.BoxWithConstraints
+                import androidx.compose.runtime.Composable
+
+                @Composable
+                fun Test() {
+                    val foo = 123
+                    BoxWithConstraints { /**/ }
+                    BoxWithConstraints { foo }
+                    BoxWithConstraints(content = { /**/ })
+                    BoxWithConstraints(propagateMinConstraints = false, content = { /**/ })
+                }
+                """.trimIndent()
+            ),
+            BoxWithConstraintsStub,
+            Stubs.Composable,
+        )
+            .run()
+            .expect(
+                """
+src/foo/test.kt:9: Error: BoxWithConstraints scope is not used [UnusedBoxWithConstraintsScope]
+    BoxWithConstraints { /**/ }
+                       ~~~~~~~~
+src/foo/test.kt:10: Error: BoxWithConstraints scope is not used [UnusedBoxWithConstraintsScope]
+    BoxWithConstraints { foo }
+                       ~~~~~~~
+src/foo/test.kt:11: Error: BoxWithConstraints scope is not used [UnusedBoxWithConstraintsScope]
+    BoxWithConstraints(content = { /**/ })
+                                 ~~~~~~~~
+src/foo/test.kt:12: Error: BoxWithConstraints scope is not used [UnusedBoxWithConstraintsScope]
+    BoxWithConstraints(propagateMinConstraints = false, content = { /**/ })
+                                                                  ~~~~~~~~
+4 errors, 0 warnings
+                """
+            )
+    }
+
+    @Test
+    fun referencedConstraints() {
+        lint().files(
+            kotlin(
+                """
+                package foo
+
+                import androidx.compose.foundation.layout.BoxWithConstraints
+                import androidx.compose.runtime.Composable
+
+                @Composable
+                fun Foo(content: @Composable ()->Unit) {}
+                @Composable
+                fun Bar() {}
+
+                @Composable
+                fun Test() {
+                    BoxWithConstraints { constraints }
+                    BoxWithConstraints { constraints.minWidth }
+                    BoxWithConstraints { minWidth }
+                    BoxWithConstraints { maxWidth }
+                    BoxWithConstraints { minHeight }
+                    BoxWithConstraints { maxHeight }
+                    BoxWithConstraints(content = { maxWidth })
+                    BoxWithConstraints(propagateMinConstraints = false, content = { minWidth })
+                    BoxWithConstraints {
+                        if (constraints.minWidth > 100) {
+                            Foo {}
+                        } else {
+                            Bar()
+                        }
+                    }
+                    BoxWithConstraints {
+                        Foo {
+                            constraints
+                        }
+                    }
+                }
+                """.trimIndent()
+            ),
+            BoxWithConstraintsStub,
+            Stubs.Composable,
+        )
+            .run()
+            .expectClean()
+    }
+
+    @Test
+    fun referencedConstraintsViaThis() {
+        lint().files(
+            kotlin(
+                """
+                package foo
+
+                import androidx.compose.foundation.layout.BoxWithConstraints
+                import androidx.compose.foundation.layout.BoxWithConstraintsScope
+                import androidx.compose.runtime.Composable
+
+                @Composable
+                fun PassOnScope(scope: BoxWithConstraintsScope) {}
+
+                @Composable
+                fun Test() {
+                    BoxWithConstraints {
+                        PassOnScope(scope = this)
+                    }
+                }
+                """.trimIndent()
+            ),
+            BoxWithConstraintsStub,
+            Stubs.Composable,
+        )
+            .run()
+            .expectClean()
+    }
+
+    @Test
+    fun referencedConstraintsViaReceiver() {
+        lint().files(
+            kotlin(
+                """
+                package foo
+
+                import androidx.compose.foundation.layout.BoxWithConstraints
+                import androidx.compose.foundation.layout.BoxWithConstraintsScope
+                import androidx.compose.runtime.Composable
+                val lambda: BoxWithConstraintsScope.() -> Unit = {}
+                fun BoxWithConstraintsScope.Func() { constraints.minWidth }
+                @Composable
+                fun BoxWithConstraintsScope.ComposableFunc() { constraints.minWidth }
+                @Composable
+                fun Foo(content: @Composable ()->Unit) {}
+                val BoxWithConstraintsScope.prop: Int
+                    get() = 0
+
+                @Composable
+                fun Test() {
+                    BoxWithConstraints {
+                        lambda()
+                    }
+                    BoxWithConstraints {
+                        Func()
+                    }
+                    BoxWithConstraints {
+                        ComposableFunc()
+                    }
+                    BoxWithConstraints {
+                        prop
+                    }
+                    BoxWithConstraints {
+                        Foo { this@BoxWithConstraints.lambda() }
+                    }
+                }
+                """.trimIndent()
+            ),
+            BoxWithConstraintsStub,
+            Stubs.Composable,
+        )
+            .run()
+            .expectClean()
+    }
+
+    @Test
+    fun referencedConstraintsInExternalModule() {
+        lint().files(
+            kotlin(
+                """
+                    package foo
+
+                    import androidx.compose.foundation.layout.BoxWithConstraints
+                    import androidx.compose.foundation.layout.BoxWithConstraintsScope
+                    import androidx.compose.runtime.Composable
+                    import bar.compose.Other
+
+                    @Composable
+                    fun Test() {
+                        BoxWithConstraints {
+                            Other()
+                        }
+                    }
+                """.trimIndent()
+            ),
+            BoxWithConstraintsStub,
+            ExternalModuleFunctionStub.bytecode,
+            Stubs.Composable
+        )
+            .run()
+            .expectClean()
+    }
+
+    @Test
+    fun referencedThisInExternalModule() {
+        lint().files(
+            ExternalModuleFunctionStub.kotlin,
+            BoxWithConstraintsStub,
+            Stubs.Composable
+        )
+            .run()
+            .expectClean()
+    }
+}
diff --git a/compose/foundation/foundation/OWNERS b/compose/foundation/foundation/OWNERS
index 9b493db..a0f35b5 100644
--- a/compose/foundation/foundation/OWNERS
+++ b/compose/foundation/foundation/OWNERS
@@ -5,6 +5,8 @@
 tianliu@google.com
 soboleva@google.com
 ashikov@google.com
+levima@google.com
+jossiwolf@google.com
 
 # Text
 include /TEXT_OWNERS
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index 478059f..a11bd66 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -39,7 +39,7 @@
   }
 
   public final class CanvasKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, String contentDescription, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
+    method @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, String contentDescription, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
     method @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
   }
 
@@ -209,6 +209,7 @@
     method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
     method public int getMaxValue();
     method public int getValue();
+    method public int getViewportSize();
     method public boolean isScrollInProgress();
     method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public suspend Object? scrollTo(int value, kotlin.coroutines.Continuation<? super java.lang.Float>);
@@ -218,6 +219,7 @@
     property public boolean isScrollInProgress;
     property public final int maxValue;
     property public final int value;
+    property public final int viewportSize;
     field public static final androidx.compose.foundation.ScrollState.Companion Companion;
   }
 
@@ -278,14 +280,14 @@
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public <T> androidx.compose.runtime.saveable.Saver<androidx.compose.foundation.gestures.AnchoredDraggableState<T>,T> Saver(androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, kotlin.jvm.functions.Function1<? super java.lang.Float,java.lang.Float> positionalThreshold, kotlin.jvm.functions.Function0<java.lang.Float> velocityThreshold, optional kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> confirmValueChange);
   }
 
-  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewScroller {
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewSpec {
     method public float calculateScrollDistance(float offset, float size, float containerSize);
-    method public androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
-    property public abstract androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
-    field public static final androidx.compose.foundation.gestures.BringIntoViewScroller.Companion Companion;
+    method public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
+    property public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
+    field public static final androidx.compose.foundation.gestures.BringIntoViewSpec.Companion Companion;
   }
 
-  public static final class BringIntoViewScroller.Companion {
+  public static final class BringIntoViewSpec.Companion {
     method public androidx.compose.animation.core.AnimationSpec<java.lang.Float> getDefaultScrollAnimationSpec();
     property public final androidx.compose.animation.core.AnimationSpec<java.lang.Float> DefaultScrollAnimationSpec;
   }
@@ -374,7 +376,7 @@
   }
 
   public final class ScrollableDefaults {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewScroller bringIntoViewScroller();
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
     method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
@@ -382,7 +384,7 @@
   }
 
   public final class ScrollableKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewScroller bringIntoViewScroller);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec);
     method public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
   }
 
@@ -716,7 +718,7 @@
 
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class GridItemSpan {
     method public int getCurrentLineSpan();
-    property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final int currentLineSpan;
+    property public final int currentLineSpan;
   }
 
   public final class LazyGridDslKt {
@@ -1082,6 +1084,7 @@
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class PagerDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.snapping.SnapFlingBehavior flingBehavior(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.foundation.pager.PagerSnapDistance pagerSnapDistance, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> lowVelocityAnimationSpec, optional androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> highVelocityAnimationSpec, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec, optional float snapVelocityThreshold, optional float snapPositionalThreshold);
     method public androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection(androidx.compose.foundation.pager.PagerState state, androidx.compose.foundation.gestures.Orientation orientation);
+    field public static final int BeyondBoundsPageCount = 0; // 0x0
     field public static final androidx.compose.foundation.pager.PagerDefaults INSTANCE;
   }
 
@@ -1095,6 +1098,7 @@
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public sealed interface PagerLayoutInfo {
     method public int getAfterContentPadding();
     method public int getBeforeContentPadding();
+    method public int getBeyondBoundsPageCount();
     method public androidx.compose.foundation.gestures.Orientation getOrientation();
     method public int getPageSize();
     method public int getPageSpacing();
@@ -1105,6 +1109,7 @@
     method public java.util.List<androidx.compose.foundation.pager.PageInfo> getVisiblePagesInfo();
     property public abstract int afterContentPadding;
     property public abstract int beforeContentPadding;
+    property public abstract int beyondBoundsPageCount;
     property public abstract androidx.compose.foundation.gestures.Orientation orientation;
     property public abstract int pageSize;
     property public abstract int pageSpacing;
@@ -1430,11 +1435,13 @@
 package androidx.compose.foundation.text2 {
 
   public final class BasicSecureTextFieldKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,java.lang.Boolean>? onSubmit, optional int imeAction, optional int textObfuscationMode, optional int keyboardType, optional boolean enabled, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super androidx.compose.ui.text.TextLayoutResult,kotlin.Unit> onTextLayout, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,java.lang.Boolean>? onSubmit, optional int imeAction, optional int textObfuscationMode, optional int keyboardType, optional boolean enabled, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
   }
 
   public final class BasicTextField2Kt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super androidx.compose.ui.text.TextLayoutResult,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
   }
 
 }
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index a608e1e..744d3fa 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -39,7 +39,7 @@
   }
 
   public final class CanvasKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, String contentDescription, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
+    method @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, String contentDescription, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
     method @androidx.compose.runtime.Composable public static void Canvas(androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function1<? super androidx.compose.ui.graphics.drawscope.DrawScope,kotlin.Unit> onDraw);
   }
 
@@ -211,6 +211,7 @@
     method public androidx.compose.foundation.interaction.InteractionSource getInteractionSource();
     method public int getMaxValue();
     method public int getValue();
+    method public int getViewportSize();
     method public boolean isScrollInProgress();
     method public suspend Object? scroll(androidx.compose.foundation.MutatePriority scrollPriority, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.gestures.ScrollScope,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public suspend Object? scrollTo(int value, kotlin.coroutines.Continuation<? super java.lang.Float>);
@@ -220,6 +221,7 @@
     property public boolean isScrollInProgress;
     property public final int maxValue;
     property public final int value;
+    property public final int viewportSize;
     field public static final androidx.compose.foundation.ScrollState.Companion Companion;
   }
 
@@ -280,14 +282,14 @@
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public <T> androidx.compose.runtime.saveable.Saver<androidx.compose.foundation.gestures.AnchoredDraggableState<T>,T> Saver(androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, kotlin.jvm.functions.Function1<? super java.lang.Float,java.lang.Float> positionalThreshold, kotlin.jvm.functions.Function0<java.lang.Float> velocityThreshold, optional kotlin.jvm.functions.Function1<? super T,java.lang.Boolean> confirmValueChange);
   }
 
-  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewScroller {
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Stable public interface BringIntoViewSpec {
     method public float calculateScrollDistance(float offset, float size, float containerSize);
-    method public androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
-    property public abstract androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
-    field public static final androidx.compose.foundation.gestures.BringIntoViewScroller.Companion Companion;
+    method public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> getScrollAnimationSpec();
+    property public default androidx.compose.animation.core.AnimationSpec<java.lang.Float> scrollAnimationSpec;
+    field public static final androidx.compose.foundation.gestures.BringIntoViewSpec.Companion Companion;
   }
 
-  public static final class BringIntoViewScroller.Companion {
+  public static final class BringIntoViewSpec.Companion {
     method public androidx.compose.animation.core.AnimationSpec<java.lang.Float> getDefaultScrollAnimationSpec();
     property public final androidx.compose.animation.core.AnimationSpec<java.lang.Float> DefaultScrollAnimationSpec;
   }
@@ -376,7 +378,7 @@
   }
 
   public final class ScrollableDefaults {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewScroller bringIntoViewScroller();
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec();
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.FlingBehavior flingBehavior();
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public androidx.compose.foundation.OverscrollEffect overscrollEffect();
     method public boolean reverseDirection(androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.foundation.gestures.Orientation orientation, boolean reverseScrolling);
@@ -384,7 +386,7 @@
   }
 
   public final class ScrollableKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewScroller bringIntoViewScroller);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, androidx.compose.foundation.OverscrollEffect? overscrollEffect, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.foundation.gestures.BringIntoViewSpec bringIntoViewSpec);
     method public static androidx.compose.ui.Modifier scrollable(androidx.compose.ui.Modifier, androidx.compose.foundation.gestures.ScrollableState state, androidx.compose.foundation.gestures.Orientation orientation, optional boolean enabled, optional boolean reverseDirection, optional androidx.compose.foundation.gestures.FlingBehavior? flingBehavior, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
   }
 
@@ -718,7 +720,7 @@
 
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class GridItemSpan {
     method public int getCurrentLineSpan();
-    property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final int currentLineSpan;
+    property public final int currentLineSpan;
   }
 
   public final class LazyGridDslKt {
@@ -1084,6 +1086,7 @@
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class PagerDefaults {
     method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.snapping.SnapFlingBehavior flingBehavior(androidx.compose.foundation.pager.PagerState state, optional androidx.compose.foundation.pager.PagerSnapDistance pagerSnapDistance, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> lowVelocityAnimationSpec, optional androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> highVelocityAnimationSpec, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec, optional float snapVelocityThreshold, optional float snapPositionalThreshold);
     method public androidx.compose.ui.input.nestedscroll.NestedScrollConnection pageNestedScrollConnection(androidx.compose.foundation.pager.PagerState state, androidx.compose.foundation.gestures.Orientation orientation);
+    field public static final int BeyondBoundsPageCount = 0; // 0x0
     field public static final androidx.compose.foundation.pager.PagerDefaults INSTANCE;
   }
 
@@ -1097,6 +1100,7 @@
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public sealed interface PagerLayoutInfo {
     method public int getAfterContentPadding();
     method public int getBeforeContentPadding();
+    method public int getBeyondBoundsPageCount();
     method public androidx.compose.foundation.gestures.Orientation getOrientation();
     method public int getPageSize();
     method public int getPageSpacing();
@@ -1107,6 +1111,7 @@
     method public java.util.List<androidx.compose.foundation.pager.PageInfo> getVisiblePagesInfo();
     property public abstract int afterContentPadding;
     property public abstract int beforeContentPadding;
+    property public abstract int beyondBoundsPageCount;
     property public abstract androidx.compose.foundation.gestures.Orientation orientation;
     property public abstract int pageSize;
     property public abstract int pageSpacing;
@@ -1432,11 +1437,13 @@
 package androidx.compose.foundation.text2 {
 
   public final class BasicSecureTextFieldKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,java.lang.Boolean>? onSubmit, optional int imeAction, optional int textObfuscationMode, optional int keyboardType, optional boolean enabled, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super androidx.compose.ui.text.TextLayoutResult,kotlin.Unit> onTextLayout, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicSecureTextField(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.ImeAction,java.lang.Boolean>? onSubmit, optional int imeAction, optional int textObfuscationMode, optional int keyboardType, optional boolean enabled, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
   }
 
   public final class BasicTextField2Kt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super androidx.compose.ui.text.TextLayoutResult,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.foundation.text2.input.TextFieldState state, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(androidx.compose.ui.text.input.TextFieldValue value, kotlin.jvm.functions.Function1<? super androidx.compose.ui.text.input.TextFieldValue,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static void BasicTextField2(String value, kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> onValueChange, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean readOnly, optional androidx.compose.foundation.text2.input.TextEditFilter? filter, optional androidx.compose.ui.text.TextStyle textStyle, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional androidx.compose.foundation.text2.input.TextFieldLineLimits lineLimits, optional kotlin.jvm.functions.Function2<? super androidx.compose.ui.unit.Density,? super kotlin.jvm.functions.Function0<androidx.compose.ui.text.TextLayoutResult>,kotlin.Unit> onTextLayout, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.ScrollState scrollState, optional androidx.compose.foundation.text2.input.CodepointTransformation? codepointTransformation, optional kotlin.jvm.functions.Function1<? super kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.Unit> decorationBox);
   }
 
 }
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 374b21a..8b575e1 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
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.demos.text2.BasicTextField2CustomPinFieldDemo
 import androidx.compose.foundation.demos.text2.BasicTextField2Demos
 import androidx.compose.foundation.demos.text2.BasicTextField2FilterDemos
+import androidx.compose.foundation.demos.text2.BasicTextField2ValueCallbackDemo
 import androidx.compose.foundation.demos.text2.DecorationBoxDemos
 import androidx.compose.foundation.demos.text2.KeyboardOptionsDemos
 import androidx.compose.foundation.demos.text2.ScrollableDemos
@@ -134,6 +135,7 @@
             "BasicTextField2",
             listOf(
                 ComposableDemo("Basic text input") { BasicTextField2Demos() },
+                ComposableDemo("Value/callback overload") { BasicTextField2ValueCallbackDemo() },
                 ComposableDemo("Keyboard Options") { KeyboardOptionsDemos() },
                 ComposableDemo("Decoration Box") { DecorationBoxDemos() },
                 ComposableDemo("Line limits") { TextFieldLineLimitsDemos() },
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2Demos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2Demos.kt
index c646116..ad9f27f 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2Demos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextField2Demos.kt
@@ -35,6 +35,7 @@
 import androidx.compose.material.Button
 import androidx.compose.material.Checkbox
 import androidx.compose.material.LocalTextStyle
+import androidx.compose.material.MaterialTheme
 import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -46,7 +47,10 @@
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.intl.Locale
 import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.toUpperCase
 import androidx.compose.ui.unit.dp
 
 @Composable
@@ -96,6 +100,55 @@
     }
 }
 
+@Composable
+fun BasicTextField2ValueCallbackDemo() {
+    Column(
+        Modifier
+            .imePadding()
+            .verticalScroll(rememberScrollState())
+    ) {
+        TagLine("Simple string-only")
+        SimpleValueCallbackDemo()
+
+        TagLine("Simple TextFieldValue")
+        SimpleTextFieldValueCallbackDemo()
+
+        TagLine("Callback changes to caps")
+        CapitalizeValueCallbackDemo()
+    }
+}
+
+@Composable
+private fun SimpleValueCallbackDemo() {
+    var text by remember { mutableStateOf("") }
+    BasicTextField2(
+        value = text,
+        onValueChange = { text = it },
+        modifier = demoTextFieldModifiers
+    )
+}
+
+@Composable
+private fun SimpleTextFieldValueCallbackDemo() {
+    var value by remember { mutableStateOf(TextFieldValue()) }
+    BasicTextField2(
+        value = value,
+        onValueChange = { value = it },
+        modifier = demoTextFieldModifiers
+    )
+}
+
+@Composable
+private fun CapitalizeValueCallbackDemo() {
+    var text by remember { mutableStateOf("") }
+    BasicTextField2(
+        value = text,
+        onValueChange = { text = it.toUpperCase(Locale.current) },
+        modifier = demoTextFieldModifiers
+    )
+    Text(text = "Backing state: \"$text\"", style = MaterialTheme.typography.caption)
+}
+
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun PlainBasicTextField2() {
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/CanvasSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/CanvasSamples.kt
index bea65d5b..7d407a1 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/CanvasSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/CanvasSamples.kt
@@ -18,7 +18,6 @@
 
 import androidx.annotation.Sampled
 import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.size
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
@@ -45,7 +44,6 @@
 }
 
 @Sampled
-@OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun CanvasPieChartSample() {
     Canvas(
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/CanvasTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/CanvasTest.kt
index e7ff30b..0144d3d 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/CanvasTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/CanvasTest.kt
@@ -164,7 +164,6 @@
     }
 
     @Test
-    @OptIn(ExperimentalFoundationApi::class)
     fun canvas_contentDescription() {
         val testTag = "canvas"
         val contentDescription = "cd"
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollTest.kt
index d6563b6..805bc22 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/ScrollTest.kt
@@ -1050,6 +1050,22 @@
         }
     }
 
+    @Test
+    fun viewPortSize_shouldRepresentScrollableLayoutSize_contentFits() {
+        val state = ScrollState(0)
+        val scrollerSize = colors.size * defaultCellSize
+        composeScroller(scrollState = state, mainAxisSize = scrollerSize)
+        assertThat(state.viewportSize).isEqualTo(scrollerSize)
+    }
+
+    @Test
+    fun viewPortSize_shouldRepresentScrollableLayoutSize_contentDoesNotFit() {
+        val state = ScrollState(0)
+        val scrollerSize = 30
+        composeScroller(scrollState = state, mainAxisSize = scrollerSize)
+        assertThat(state.viewportSize).isEqualTo(scrollerSize)
+    }
+
     private fun Modifier.intrinsicMainAxisSize(size: IntrinsicSize): Modifier =
         if (config.orientation == Horizontal) {
             width(size)
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt
index 536921e..b8eebfbe 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/anchoredDraggable/AnchoredDraggableStateTest.kt
@@ -962,6 +962,51 @@
         assertThat(state.offset).isNaN()
     }
 
+    @Test
+    fun anchoredDraggable_customDrag_settleOnInvalidState_shouldRespectConfirmValueChange() =
+        runBlocking {
+            var shouldBlockValueC = false
+            val state = AnchoredDraggableState(
+                initialValue = B,
+                positionalThreshold = defaultPositionalThreshold,
+                velocityThreshold = defaultVelocityThreshold,
+                animationSpec = defaultAnimationSpec,
+                confirmValueChange = {
+                    if (shouldBlockValueC)
+                        it != C // block state value C
+                    else
+                        true
+                }
+            )
+            val anchors = DraggableAnchors {
+                A at 0f
+                B at 200f
+                C at 300f
+            }
+
+            state.updateAnchors(anchors)
+            state.anchoredDrag {
+                dragTo(300f)
+            }
+
+            // confirm we can actually go to C
+            assertThat(state.currentValue).isEqualTo(C)
+
+            // go back to B
+            state.anchoredDrag {
+                dragTo(200f)
+            }
+            assertThat(state.currentValue).isEqualTo(B)
+
+            // disallow C
+            shouldBlockValueC = true
+
+            state.anchoredDrag {
+                dragTo(300f)
+            }
+            assertThat(state.currentValue).isNotEqualTo(C)
+        }
+
     private suspend fun suspendIndefinitely() = suspendCancellableCoroutine<Unit> { }
 
     private class HandPumpTestFrameClock : MonotonicFrameClock {
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
index 1c1eb34..b1ff435 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/BasePagerTest.kt
@@ -114,7 +114,7 @@
         initialPageOffsetFraction: Float = 0f,
         pageCount: () -> Int = { DefaultPageCount },
         modifier: Modifier = Modifier,
-        offscreenPageLimit: Int = config.beyondBoundsPageCount,
+        beyondBoundsPageCount: Int = config.beyondBoundsPageCount,
         pageSize: () -> PageSize = { PageSize.Fill },
         userScrollEnabled: Boolean = true,
         snappingPage: PagerSnapDistance = PagerSnapDistance.atMost(1),
@@ -154,7 +154,7 @@
                 ) {
                     HorizontalOrVerticalPager(
                         state = state,
-                        beyondBoundsPageCount = offscreenPageLimit,
+                        beyondBoundsPageCount = beyondBoundsPageCount,
                         modifier = modifier
                             .testTag(PagerTestTag)
                             .onSizeChanged { pagerSize = if (vertical) it.height else it.width },
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PageSizeTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PageSizeTest.kt
index 7f9e466..9530d6e 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PageSizeTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PageSizeTest.kt
@@ -63,7 +63,7 @@
         createPager(
             initialPage = 5,
             modifier = Modifier.crossAxisSize(200.dp),
-            offscreenPageLimit = 0,
+            beyondBoundsPageCount = 0,
             pageSize = { pagerMode }
         )
 
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
index ac34782..696c7c2 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
@@ -53,7 +53,7 @@
 
     @Test
     fun accessibilityScroll_scrollToPage() {
-        createPager(offscreenPageLimit = 1)
+        createPager(beyondBoundsPageCount = 1)
 
         rule.runOnIdle { assertThat(pagerState.currentPage).isEqualTo(0) }
 
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerOffscreenPageLimitPlacingTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerOffscreenPageLimitPlacingTest.kt
index db60199..055d7f6 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerOffscreenPageLimitPlacingTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerOffscreenPageLimitPlacingTest.kt
@@ -45,7 +45,7 @@
         createPager(
             pageCount = { DefaultPageCount },
             modifier = Modifier.fillMaxSize(),
-            offscreenPageLimit = 1
+            beyondBoundsPageCount = 1
         )
         val delta = pagerSize * 1.4f * scrollForwardSign
 
@@ -79,7 +79,7 @@
             initialPage = initialIndex,
             pageCount = { DefaultPageCount },
             modifier = Modifier.fillMaxSize(),
-            offscreenPageLimit = 2
+            beyondBoundsPageCount = 2
         )
         val firstVisible = pagerState.layoutInfo.visiblePagesInfo.first().index
         val lastVisible = pagerState.layoutInfo.visiblePagesInfo.last().index
@@ -105,7 +105,7 @@
             initialPage = 5,
             pageCount = { DefaultPageCount },
             modifier = Modifier.fillMaxSize(),
-            offscreenPageLimit = 0
+            beyondBoundsPageCount = 0
         )
 
         // Assert
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerPrefetcherTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerPrefetcherTest.kt
index fd60459d..4340b66 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerPrefetcherTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/pager/PagerPrefetcherTest.kt
@@ -93,19 +93,20 @@
 
     @Test
     fun prefetchingBackwardAfterSmallScroll_programmatically() {
-        composePager(initialPage = 2, initialPageOffsetFraction = 10 / pageSizePx.toFloat())
+        composePager(initialPage = 5, initialPageOffsetFraction = 10 / pageSizePx.toFloat())
 
+        val preFetchIndex = 4
         rule.runOnIdle {
             runBlocking {
                 pagerState.scrollBy(-5f)
             }
         }
 
-        waitForPrefetch(1)
+        waitForPrefetch(preFetchIndex)
 
-        rule.onNodeWithTag("1")
+        rule.onNodeWithTag("$preFetchIndex")
             .assertExists()
-        rule.onNodeWithTag("0")
+        rule.onNodeWithTag("${preFetchIndex - paramConfig.beyondBoundsPageCount - 1}")
             .assertDoesNotExist()
     }
 
@@ -135,8 +136,9 @@
 
     @Test
     fun prefetchingBackwardAfterSmallScroll_withGesture() {
-        composePager(initialPage = 2, initialPageOffsetFraction = 10 / pageSizePx.toFloat())
+        composePager(initialPage = 5, initialPageOffsetFraction = 10 / pageSizePx.toFloat())
 
+        val preFetchIndex = 4
         val delta = (touchSlope + 5) * -1 * scrollForwardSign
 
         onPager().performTouchInput {
@@ -149,11 +151,11 @@
             up()
         }
 
-        waitForPrefetch(1)
+        waitForPrefetch(preFetchIndex)
 
-        rule.onNodeWithTag("1")
+        rule.onNodeWithTag("$preFetchIndex")
             .assertExists()
-        rule.onNodeWithTag("0")
+        rule.onNodeWithTag("${preFetchIndex - paramConfig.beyondBoundsPageCount - 1}")
             .assertDoesNotExist()
     }
 
@@ -224,7 +226,9 @@
 
     @Test
     fun prefetchingBackwardTwice() {
-        composePager(initialPage = 4)
+        composePager(initialPage = 5)
+
+        val preFetchIndex = 3
 
         rule.runOnIdle {
             runBlocking {
@@ -232,7 +236,7 @@
             }
         }
 
-        waitForPrefetch(2)
+        waitForPrefetch(preFetchIndex)
 
         rule.runOnIdle {
             runBlocking {
@@ -241,13 +245,13 @@
             }
         }
 
-        waitForPrefetch(1)
+        waitForPrefetch(preFetchIndex - 1)
 
-        rule.onNodeWithTag("2")
+        rule.onNodeWithTag("$preFetchIndex")
             .assertIsDisplayed()
-        rule.onNodeWithTag("1")
+        rule.onNodeWithTag("${preFetchIndex - 1}")
             .assertExists()
-        rule.onNodeWithTag("0")
+        rule.onNodeWithTag("${preFetchIndex - 1 - paramConfig.beyondBoundsPageCount - 1}")
             .assertDoesNotExist()
     }
 
@@ -492,7 +496,7 @@
             modifier = Modifier.mainAxisSize(pageSizeDp * 1.5f),
             reverseLayout = reverseLayout,
             contentPadding = contentPadding,
-            offscreenPageLimit = paramConfig.beyondBoundsPageCount,
+            beyondBoundsPageCount = paramConfig.beyondBoundsPageCount,
             initialPage = initialPage,
             initialPageOffsetFraction = initialPageOffsetFraction,
             pageCount = { 100 },
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/relocation/BringIntoViewScrollableInteractionTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/relocation/BringIntoViewScrollableInteractionTest.kt
index dd10d64..0ea5c2a 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/relocation/BringIntoViewScrollableInteractionTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/relocation/BringIntoViewScrollableInteractionTest.kt
@@ -22,7 +22,7 @@
 import androidx.compose.foundation.ScrollState
 import androidx.compose.foundation.ScrollingLayoutElement
 import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.BringIntoViewScroller
+import androidx.compose.foundation.gestures.BringIntoViewSpec
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.Orientation.Horizontal
 import androidx.compose.foundation.gestures.Orientation.Vertical
@@ -1207,7 +1207,7 @@
         }
 
         val expectedContainerSize = with(rule.density) { containerSize.roundToPx() }
-        val customBringIntoViewScroller = object : BringIntoViewScroller {
+        val customBringIntoViewSpec = object : BringIntoViewSpec {
             override val scrollAnimationSpec: AnimationSpec<Float> = spring()
 
             override fun calculateScrollDistance(
@@ -1232,7 +1232,7 @@
                         state = state,
                         overscrollEffect = null,
                         orientation = orientation,
-                        bringIntoViewScroller = customBringIntoViewScroller
+                        bringIntoViewSpec = customBringIntoViewSpec
                     )
                     .then(ScrollingLayoutElement(state, false, orientation == Vertical))
             ) {
@@ -1252,7 +1252,7 @@
         val bringIntoViewRequests = listOf(300f, 150f, 0f)
         val scrollState = ScrollState(0)
         var requestsFulfilledScroll = 0
-        val customBringIntoViewScroller = object : BringIntoViewScroller {
+        val customBringIntoViewSpec = object : BringIntoViewSpec {
             var index = 0
 
             override val scrollAnimationSpec: AnimationSpec<Float> = spring()
@@ -1283,7 +1283,7 @@
                         state = scrollState,
                         overscrollEffect = null,
                         orientation = orientation,
-                        bringIntoViewScroller = customBringIntoViewScroller
+                        bringIntoViewSpec = customBringIntoViewSpec
                     )
             ) {
                 Box(
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/selection/AbstractSelectionMagnifierTests.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/selection/AbstractSelectionMagnifierTests.kt
index b89fd7e..854d909 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/selection/AbstractSelectionMagnifierTests.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/selection/AbstractSelectionMagnifierTests.kt
@@ -113,17 +113,17 @@
     }
 
     @Test
-    open fun magnifier_appears_duringInitialLongPressDrag_expandingForwards() {
+    fun magnifier_appears_duringInitialLongPressDrag_expandingForwards() {
         checkMagnifierShowsDuringInitialLongPressDrag(expandForwards = true)
     }
 
     @Test
-    open fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards() {
+    fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards() {
         checkMagnifierShowsDuringInitialLongPressDrag(expandForwards = false)
     }
 
     @Test
-    open fun magnifier_appears_duringInitialLongPressDrag_expandingForwards_rtl() {
+    fun magnifier_appears_duringInitialLongPressDrag_expandingForwards_rtl() {
         checkMagnifierShowsDuringInitialLongPressDrag(
             expandForwards = true,
             layoutDirection = LayoutDirection.Rtl
@@ -131,7 +131,7 @@
     }
 
     @Test
-    open fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards_rtl() {
+    fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards_rtl() {
         checkMagnifierShowsDuringInitialLongPressDrag(
             expandForwards = false,
             layoutDirection = LayoutDirection.Rtl
@@ -607,7 +607,7 @@
      */
     // TODO(b/210545925) This is here because we can't disable the touch slop in a popup. When
     //  that's fixed we can just disable slop and delete this function.
-    private fun TouchInjectionScope.movePastSlopBy(delta: Offset) {
+    protected fun TouchInjectionScope.movePastSlopBy(delta: Offset) {
         val slop = Offset(
             x = viewConfiguration.touchSlop * delta.x.sign,
             y = viewConfiguration.touchSlop * delta.y.sign
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicSecureTextFieldTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicSecureTextFieldTest.kt
index 8943967..77c7607 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicSecureTextFieldTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicSecureTextFieldTest.kt
@@ -62,6 +62,8 @@
 @RunWith(AndroidJUnit4::class)
 class BasicSecureTextFieldTest {
 
+    // Keyboard shortcut tests for BasicSecureTextField are in TextFieldKeyEventTest
+
     @get:Rule
     val rule = createComposeRule().apply {
         mainClock.autoAdvance = false
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2ImmIntegrationTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2ImmIntegrationTest.kt
index 9458876..ea979e4 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2ImmIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2ImmIntegrationTest.kt
@@ -249,6 +249,9 @@
             connection.commitText("hello", 1)
 
             assertThat(state.text.toString()).isEqualTo("helloworld")
+        }
+
+        rule.runOnIdle {
             imm.expectCall("restartInput")
             imm.expectNoMoreCalls()
         }
@@ -297,6 +300,9 @@
         rule.runOnIdle {
             imm.resetCalls()
             inputConnection!!.setComposingText("hello", 1)
+        }
+
+        rule.runOnIdle {
             imm.expectCall("updateSelection(0, 5, 0, 5)")
             imm.expectNoMoreCalls()
         }
@@ -316,7 +322,9 @@
                 append("hello")
                 placeCursorBeforeCharAt(0)
             }
+        }
 
+        rule.runOnIdle {
             imm.expectCall("restartInput")
             imm.expectNoMoreCalls()
         }
@@ -335,7 +343,9 @@
             state.edit {
                 placeCursorAtEnd()
             }
+        }
 
+        rule.runOnIdle {
             imm.expectCall("updateSelection(5, 5, -1, -1)")
             imm.expectNoMoreCalls()
         }
@@ -355,7 +365,9 @@
                 append("hello")
                 placeCursorAtEnd()
             }
+        }
 
+        rule.runOnIdle {
             imm.expectCall("updateSelection(5, 5, -1, -1)")
             imm.expectCall("restartInput")
             imm.expectNoMoreCalls()
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2Test.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2Test.kt
index 093c9bf..cfabe0a 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2Test.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/BasicTextField2Test.kt
@@ -38,11 +38,13 @@
 import androidx.compose.foundation.text2.input.rememberTextFieldState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.key.Key
@@ -74,14 +76,16 @@
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.KeyboardCapitalization
 import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
+import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertFails
+import kotlinx.coroutines.flow.drop
 import org.junit.After
 import org.junit.Ignore
 import org.junit.Rule
@@ -89,7 +93,7 @@
 import org.junit.runner.RunWith
 
 @OptIn(ExperimentalFoundationApi::class, ExperimentalTestApi::class)
-@MediumTest
+@LargeTest
 @RunWith(AndroidJUnit4::class)
 internal class BasicTextField2Test {
     @get:Rule
@@ -104,7 +108,7 @@
 
     @Test
     fun textField_rendersEmptyContent() {
-        var textLayoutResult: TextLayoutResult? = null
+        var textLayoutResult: (() -> TextLayoutResult?)? = null
         rule.setContent {
             val state = remember { TextFieldState() }
             BasicTextField2(
@@ -116,12 +120,12 @@
 
         rule.runOnIdle {
             assertThat(textLayoutResult).isNotNull()
-            assertThat(textLayoutResult?.layoutInput?.text).isEqualTo(AnnotatedString(""))
+            assertThat(textLayoutResult?.invoke()?.layoutInput?.text).isEqualTo(AnnotatedString(""))
         }
     }
 
     @Test
-    fun textField_contentChange_updatesState() {
+    fun textFieldState_textChange_updatesState() {
         val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
         rule.setContent {
             BasicTextField2(
@@ -137,11 +141,65 @@
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo("Hello World!")
         }
+    }
+
+    @Test
+    fun textFieldState_textChange_updatesSemantics() {
+        val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .testTag(Tag)
+            )
+        }
+
+        rule.onNodeWithTag(Tag).performTextInput("World!")
 
         rule.onNodeWithTag(Tag).assertTextEquals("Hello World!")
-        val selection = rule.onNodeWithTag(Tag).fetchSemanticsNode()
-            .config.getOrNull(TextSelectionRange)
-        assertThat(selection).isEqualTo(TextRange("Hello World!".length))
+        assertTextSelection(TextRange("Hello World!".length))
+    }
+
+    @Test
+    fun stringValue_textChange_updatesState() {
+        var state by mutableStateOf("Hello ")
+        rule.setContent {
+            BasicTextField2(
+                value = state,
+                onValueChange = { state = it },
+                modifier = Modifier
+                    .fillMaxSize()
+                    .testTag(Tag)
+            )
+        }
+
+        rule.onNodeWithTag(Tag).performTextInput("World!")
+
+        rule.runOnIdle {
+            assertThat(state).isEqualTo("Hello World!")
+        }
+    }
+
+    @Test
+    fun textFieldValue_textChange_updatesState() {
+        var state by mutableStateOf(TextFieldValue("Hello ", selection = TextRange(Int.MAX_VALUE)))
+        rule.setContent {
+            BasicTextField2(
+                value = state,
+                onValueChange = { state = it },
+                modifier = Modifier
+                    .fillMaxSize()
+                    .testTag(Tag)
+            )
+        }
+
+        rule.onNodeWithTag(Tag).performTextInput("World!")
+
+        rule.runOnIdle {
+            assertThat(state.text).isEqualTo("Hello World!")
+            assertThat(state.selection).isEqualTo(TextRange(12))
+        }
     }
 
     /**
@@ -178,7 +236,8 @@
     fun textField_textStyleFontSizeChange_relayouts() {
         val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
         var style by mutableStateOf(TextStyle(fontSize = 20.sp))
-        val textLayoutResults = mutableListOf<TextLayoutResult>()
+        var textLayoutResultState: (() -> TextLayoutResult?)? by mutableStateOf(null)
+        val textLayoutResults = mutableListOf<TextLayoutResult?>()
         rule.setContent {
             BasicTextField2(
                 state = state,
@@ -186,16 +245,23 @@
                     .fillMaxSize()
                     .testTag(Tag),
                 textStyle = style,
-                onTextLayout = { textLayoutResults += it }
+                onTextLayout = { textLayoutResultState = it }
             )
+
+            LaunchedEffect(Unit) {
+                snapshotFlow { textLayoutResultState?.invoke() }
+                    .drop(1)
+                    .collect { textLayoutResults += it }
+            }
         }
 
         style = TextStyle(fontSize = 30.sp)
 
         rule.runOnIdle {
             assertThat(textLayoutResults.size).isEqualTo(2)
-            assertThat(textLayoutResults.map { it.layoutInput.style.fontSize })
-                .isEqualTo(listOf(20.sp, 30.sp))
+            assertThat(textLayoutResults.map { it?.layoutInput?.style?.fontSize })
+                .containsExactly(20.sp, 30.sp)
+                .inOrder()
         }
     }
 
@@ -203,7 +269,7 @@
     fun textField_textStyleColorChange_doesNotRelayout() {
         val state = TextFieldState("Hello")
         var style by mutableStateOf(TextStyle(color = Color.Red))
-        val textLayoutResults = mutableListOf<TextLayoutResult>()
+        val textLayoutResults = mutableListOf<() -> TextLayoutResult?>()
         rule.setContent {
             BasicTextField2(
                 state = state,
@@ -219,31 +285,38 @@
 
         rule.runOnIdle {
             assertThat(textLayoutResults.size).isEqualTo(2)
-            assertThat(textLayoutResults[0].multiParagraph)
-                .isSameInstanceAs(textLayoutResults[1].multiParagraph)
+            assertThat(textLayoutResults[0]()?.multiParagraph)
+                .isSameInstanceAs(textLayoutResults[1]()?.multiParagraph)
         }
     }
 
     @Test
     fun textField_contentChange_relayouts() {
         val state = TextFieldState("Hello ", TextRange(Int.MAX_VALUE))
-        val textLayoutResults = mutableListOf<TextLayoutResult>()
+        var textLayoutResultState: (() -> TextLayoutResult?)? by mutableStateOf(null)
+        val textLayoutResults = mutableListOf<TextLayoutResult?>()
         rule.setContent {
             BasicTextField2(
                 state = state,
                 modifier = Modifier
                     .fillMaxSize()
                     .testTag(Tag),
-                onTextLayout = { textLayoutResults += it }
+                onTextLayout = { textLayoutResultState = it }
             )
+
+            LaunchedEffect(Unit) {
+                snapshotFlow { textLayoutResultState?.invoke() }
+                    .drop(1)
+                    .collect { textLayoutResults += it }
+            }
         }
 
         rule.onNodeWithTag(Tag).performTextInput("World!")
 
         rule.runOnIdle {
-            assertThat(textLayoutResults.size).isEqualTo(2)
-            assertThat(textLayoutResults.map { it.layoutInput.text.text })
-                .isEqualTo(listOf("Hello ", "Hello World!"))
+            assertThat(textLayoutResults.map { it?.layoutInput?.text?.text })
+                .containsExactly("Hello ", "Hello World!")
+                .inOrder()
         }
     }
 
@@ -1017,9 +1090,296 @@
         assertThat(secondSize.height).isEqualTo(firstSize.height * 2)
     }
 
+    @Test
+    fun stringValue_updatesFieldText_whenTextChangedFromCode_whileUnfocused() {
+        var text by mutableStateOf("hello")
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+
+        rule.runOnIdle {
+            text = "world"
+        }
+
+        rule.onNodeWithTag(Tag).assertTextEquals("world")
+    }
+
+    @Test
+    fun textFieldValue_updatesFieldText_whenTextChangedFromCode_whileUnfocused() {
+        var text by mutableStateOf(TextFieldValue("hello"))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+
+        rule.runOnIdle {
+            text = text.copy(text = "world")
+        }
+
+        rule.onNodeWithTag(Tag).assertTextEquals("world")
+    }
+
+    @Test
+    fun textFieldValue_updatesFieldSelection_whenSelectionChangedFromCode_whileUnfocused() {
+        var text by mutableStateOf(TextFieldValue("hello", selection = TextRange(1)))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+
+        rule.runOnIdle {
+            text = text.copy(selection = TextRange(2))
+        }
+
+        assertTextSelection(TextRange(2))
+    }
+
+    @Test
+    fun stringValue_doesNotUpdateField_whenTextChangedFromCode_whileFocused() {
+        var text by mutableStateOf("hello")
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        requestFocus(Tag)
+
+        rule.runOnIdle {
+            text = "world"
+        }
+
+        rule.onNodeWithTag(Tag).assertTextEquals("hello")
+    }
+
+    @Test
+    fun textFieldValue_doesNotUpdateField_whenTextChangedFromCode_whileFocused() {
+        var text by mutableStateOf(TextFieldValue("hello", selection = TextRange(1)))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        requestFocus(Tag)
+
+        rule.runOnIdle {
+            text = TextFieldValue(text = "world", selection = TextRange(2))
+        }
+
+        rule.onNodeWithTag(Tag).assertTextEquals("hello")
+    }
+
+    @Test
+    fun stringValue_doesNotInvokeCallback_onFocus() {
+        var text by mutableStateOf("")
+        var onValueChangedCount = 0
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = {
+                    text = it
+                    onValueChangedCount++
+                },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        assertThat(onValueChangedCount).isEqualTo(0)
+
+        requestFocus(Tag)
+
+        rule.runOnIdle {
+            assertThat(onValueChangedCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun stringValue_doesNotInvokeCallback_whenOnlySelectionChanged() {
+        var text by mutableStateOf("")
+        var onValueChangedCount = 0
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = {
+                    text = it
+                    onValueChangedCount++
+                },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        requestFocus(Tag)
+        assertThat(onValueChangedCount).isEqualTo(0)
+
+        // Act: wiggle the cursor around a bit.
+        rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(0))
+        rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(5))
+
+        rule.runOnIdle {
+            assertThat(onValueChangedCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun stringValue_doesNotInvokeCallback_whenOnlyCompositionChanged() {
+        lateinit var inputConnection: InputConnection
+        setInputConnectionCreatedListenerForTests { _, ic ->
+            inputConnection = ic
+        }
+        var text by mutableStateOf("")
+        var onValueChangedCount = 0
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = {
+                    text = it
+                    onValueChangedCount++
+                },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        requestFocus(Tag)
+        assertThat(onValueChangedCount).isEqualTo(0)
+
+        // Act: wiggle the composition around a bit
+        rule.runOnIdle { inputConnection.setComposingRegion(0, 0) }
+        rule.runOnIdle { inputConnection.setComposingRegion(3, 5) }
+
+        rule.runOnIdle {
+            assertThat(onValueChangedCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun stringValue_doesNotInvokeCallback_whenTextChangedFromCode_whileUnfocused() {
+        var text by mutableStateOf("")
+        var onValueChangedCount = 0
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = {
+                    text = it
+                    onValueChangedCount++
+                },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        assertThat(onValueChangedCount).isEqualTo(0)
+
+        rule.runOnIdle {
+            text = "hello"
+        }
+
+        rule.runOnIdle {
+            assertThat(onValueChangedCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun stringValue_doesNotInvokeCallback_whenTextChangedFromCode_whileFocused() {
+        var text by mutableStateOf("")
+        var onValueChangedCount = 0
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = {
+                    text = it
+                    onValueChangedCount++
+                },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        assertThat(onValueChangedCount).isEqualTo(0)
+        requestFocus(Tag)
+
+        rule.runOnIdle {
+            text = "hello"
+        }
+
+        rule.runOnIdle {
+            assertThat(onValueChangedCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun textFieldValue_usesInitialSelectionFromValue() {
+        var text by mutableStateOf(TextFieldValue("hello", selection = TextRange(2)))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+
+        assertTextSelection(TextRange(2))
+    }
+
+    @Test
+    fun textFieldValue_reportsSelectionChangesInCallback() {
+        var text by mutableStateOf(TextFieldValue("hello", selection = TextRange(1)))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+
+        rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(2))
+
+        rule.runOnIdle {
+            assertThat(text.selection).isEqualTo(TextRange(2))
+        }
+    }
+
+    @Test
+    fun textFieldValue_reportsCompositionChangesInCallback() {
+        lateinit var inputConnection: InputConnection
+        setInputConnectionCreatedListenerForTests { _, ic ->
+            inputConnection = ic
+        }
+        var text by mutableStateOf(TextFieldValue("hello", selection = TextRange(1)))
+        rule.setContent {
+            BasicTextField2(
+                value = text,
+                onValueChange = { text = it },
+                modifier = Modifier.testTag(Tag)
+            )
+        }
+        requestFocus(Tag)
+
+        rule.runOnIdle { inputConnection.setComposingRegion(0, 0) }
+        rule.runOnIdle {
+            assertThat(text.composition).isNull()
+        }
+
+        rule.runOnIdle { inputConnection.setComposingRegion(1, 4) }
+        rule.runOnIdle {
+            assertThat(text.composition).isEqualTo(TextRange(1, 4))
+        }
+    }
+
     private fun requestFocus(tag: String) =
         rule.onNodeWithTag(tag).requestFocus()
 
+    private fun assertTextSelection(expected: TextRange) {
+        val selection = rule.onNodeWithTag(Tag).fetchSemanticsNode()
+            .config.getOrNull(TextSelectionRange)
+        assertThat(selection).isEqualTo(expected)
+    }
+
     private fun InputConnection.commitText(text: String) {
         beginBatchEdit()
         finishComposingText()
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/HeightInLinesModifierTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/HeightInLinesModifierTest.kt
index 4676b33..27f3b4a 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/HeightInLinesModifierTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/HeightInLinesModifierTest.kt
@@ -82,7 +82,7 @@
 
     @Test
     fun minLines_shortInputText() {
-        var subjectLayout: TextLayoutResult? = null
+        var subjectLayout: (() -> TextLayoutResult?)? = null
         var subjectHeight: Int? = null
         var twoLineHeight: Int? = null
         val positionedLatch = CountDownLatch(1)
@@ -115,7 +115,7 @@
 
         rule.runOnIdle {
             assertThat(subjectLayout).isNotNull()
-            assertThat(subjectLayout!!.lineCount).isEqualTo(1)
+            assertThat(subjectLayout!!.invoke()?.lineCount).isEqualTo(1)
             assertThat(subjectHeight!!).isEqualTo(twoLineHeight)
         }
     }
@@ -129,8 +129,8 @@
 
         rule.runOnIdle {
             assertThat(textLayoutResult).isNotNull()
-            assertThat(textLayoutResult!!.lineCount).isEqualTo(1)
-            assertThat(textLayoutResult.size.height).isEqualTo(height)
+            assertThat(textLayoutResult!!.invoke()?.lineCount).isEqualTo(1)
+            assertThat(textLayoutResult()?.size?.height).isEqualTo(height)
         }
     }
 
@@ -141,7 +141,7 @@
 
         rule.runOnIdle {
             assertThat(textLayoutResult).isNotNull()
-            assertThat(textLayoutResult!!.size.height).isEqualTo(height)
+            assertThat(textLayoutResult!!.invoke()?.size?.height).isEqualTo(height)
         }
     }
 
@@ -186,14 +186,14 @@
         rule.runOnIdle {
             assertThat(textLayoutResult).isNotNull()
             // should be in the 20s, but use this to create invariant for the next assertion
-            assertThat(textLayoutResult!!.lineCount).isGreaterThan(2)
-            assertThat(textLayoutResult.size.height).isEqualTo(height)
+            assertThat(textLayoutResult!!.invoke()?.lineCount).isGreaterThan(2)
+            assertThat(textLayoutResult()?.size?.height).isEqualTo(height)
         }
     }
 
     @Test
     fun maxLines_longInputText() {
-        var subjectLayout: TextLayoutResult? = null
+        var subjectLayout: (() -> TextLayoutResult?)? = null
         var subjectHeight: Int? = null
         var twoLineHeight: Int? = null
         val positionedLatch = CountDownLatch(1)
@@ -227,7 +227,7 @@
         rule.runOnIdle {
             assertThat(subjectLayout).isNotNull()
             // should be in the 20s, but use this to create invariant for the next assertion
-            assertThat(subjectLayout!!.lineCount).isGreaterThan(2)
+            assertThat(subjectLayout!!.invoke()?.lineCount).isGreaterThan(2)
             assertThat(subjectHeight!!).isEqualTo(twoLineHeight)
         }
     }
@@ -308,8 +308,8 @@
     private fun setTextFieldWithMaxLines(
         text: String,
         lines: MultiLine
-    ): Pair<TextLayoutResult?, Int?> {
-        var textLayoutResult: TextLayoutResult? = null
+    ): Pair<(() -> TextLayoutResult?)?, Int?> {
+        var textLayoutResult: (() -> TextLayoutResult?)? = null
         var height: Int? = null
         val positionedLatch = CountDownLatch(1)
 
@@ -334,7 +334,7 @@
     @Composable
     private fun HeightObservingText(
         onGlobalHeightPositioned: (Int) -> Unit,
-        onTextLayoutResult: Density.(TextLayoutResult) -> Unit,
+        onTextLayoutResult: Density.(getResult: () -> TextLayoutResult?) -> Unit,
         text: String,
         lineLimits: MultiLine,
         textStyle: TextStyle = TextStyle.Default
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldCursorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldCursorTest.kt
index 0e39776..9534140 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldCursorTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldCursorTest.kt
@@ -114,10 +114,11 @@
     )
 
     private var isFocused = false
-    private var textLayoutResult: TextLayoutResult? = null
+    private var textLayoutResult: (() -> TextLayoutResult?)? = null
     private val cursorRect: Rect
         // assume selection is collapsed
-        get() = textLayoutResult?.getCursorRect(state.text.selectionInChars.start) ?: Rect.Zero
+        get() = textLayoutResult?.invoke()?.getCursorRect(state.text.selectionInChars.start)
+            ?: Rect.Zero
 
     private val cursorSize: DpSize by lazy {
         with(rule.density) {
@@ -146,7 +147,7 @@
         .then(focusModifier)
 
     // default onTextLayout to capture cursor boundaries.
-    private val onTextLayout: Density.(TextLayoutResult) -> Unit = { textLayoutResult = it }
+    private val onTextLayout: Density.(() -> TextLayoutResult?) -> Unit = { textLayoutResult = it }
 
     private fun ComposeTestRule.setTestContent(
         content: @Composable () -> Unit
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
index d2186e6..04543ff 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
@@ -15,6 +15,7 @@
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.KeyboardHelper
 import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.input.rememberTextFieldState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -242,7 +243,6 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    @Ignore // TODO(halilibo): reenable when dpad focus modifier does not use TextFieldState
     fun basicTextField_checkFocusNavigation_onDPadLeft() {
         setupAndEnableBasicTextField()
         inputSingleLineTextInBasicTextField()
@@ -252,7 +252,7 @@
         rule.waitForIdle()
 
         // Move focus to the focusable element on left
-        keyPressOnPhysicalKeyboard(rule, NativeKeyEvent.KEYCODE_DPAD_LEFT)
+        if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_LEFT)) return
 
         // Check if the element to the left of text field gains focus
         rule.onNodeWithTag("test-button-left").assertIsFocused()
@@ -260,7 +260,6 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    @Ignore // TODO(halilibo): reenable when dpad focus modifier does not use TextFieldState
     fun basicTextField_checkFocusNavigation_onDPadRight() {
         setupAndEnableBasicTextField()
         inputSingleLineTextInBasicTextField()
@@ -270,7 +269,7 @@
         rule.waitForIdle()
 
         // Move focus to the focusable element on right
-        keyPressOnPhysicalKeyboard(rule, NativeKeyEvent.KEYCODE_DPAD_RIGHT)
+        if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_RIGHT)) return
 
         // Check if the element to the right of text field gains focus
         rule.onNodeWithTag("test-button-right").assertIsFocused()
@@ -278,7 +277,6 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    @Ignore // TODO(halilibo): reenable when dpad focus modifier does not use TextFieldState
     fun basicTextField_checkFocusNavigation_onDPadUp() {
         setupAndEnableBasicTextField()
         inputMultilineTextInBasicTextField()
@@ -288,7 +286,7 @@
         rule.waitForIdle()
 
         // Move focus to the focusable element on top
-        keyPressOnPhysicalKeyboard(rule, NativeKeyEvent.KEYCODE_DPAD_UP)
+        if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_UP)) return
 
         // Check if the element on the top of text field gains focus
         rule.onNodeWithTag("test-button-top").assertIsFocused()
@@ -296,7 +294,6 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    @Ignore // TODO(halilibo): re-enable when dpad focus modifier does not use TextFieldState
     fun basicTextField_checkFocusNavigation_onDPadDown() {
         setupAndEnableBasicTextField()
         inputMultilineTextInBasicTextField()
@@ -306,7 +303,7 @@
         rule.waitForIdle()
 
         // Move focus to the focusable element on bottom
-        keyPressOnPhysicalKeyboard(rule, NativeKeyEvent.KEYCODE_DPAD_DOWN)
+        if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_DOWN)) return
 
         // Check if the element to the bottom of text field gains focus
         rule.onNodeWithTag("test-button-bottom").assertIsFocused()
@@ -326,7 +323,7 @@
         }
 
         // Check if keyboard is enabled on Dpad center key press
-        keyPressOnPhysicalKeyboard(rule, NativeKeyEvent.KEYCODE_DPAD_CENTER)
+        if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_CENTER)) return
         keyboardHelper.waitForKeyboardVisibility(true)
         rule.runOnIdle {
             assertThat(keyboardHelper.isSoftwareKeyboardShown()).isTrue()
@@ -396,7 +393,7 @@
                 }
                 Row() {
                     TestFocusableElement(id = "left")
-                    TestBasicTextField(id = "1", requestFocus = true)
+                    TestBasicTextField2(id = "1", requestFocus = true)
                     TestFocusableElement(id = "right")
                 }
                 Row(horizontalArrangement = Arrangement.Center) {
@@ -426,13 +423,11 @@
     }
 
     @Composable
-    private fun TestBasicTextField(
+    private fun TestBasicTextField2(
         id: String,
         requestFocus: Boolean = false
     ) {
-        var textInput by remember {
-            mutableStateOf("")
-        }
+        val state = rememberTextFieldState()
         var isFocused by remember {
             mutableStateOf(false)
         }
@@ -441,11 +436,8 @@
         }
         val modifier = if (requestFocus) Modifier.focusRequester(focusRequester) else Modifier
 
-        BasicTextField(
-            value = textInput,
-            onValueChange = {
-                textInput = it
-            },
+        BasicTextField2(
+            state = state,
             modifier = modifier
                 .testTag("test-text-field-$id")
                 .padding(10.dp)
@@ -460,28 +452,41 @@
         }
     }
 
-    // Triggers a key press on the root node from a non-virtual device
-    private fun keyPressOnPhysicalKeyboard(
+    // Triggers a key press on the root node from a non-virtual dpad device (if supported)
+    private fun keyPressOnDpadInputDevice(
         rule: ComposeContentTestRule,
         keyCode: Int,
         count: Int = 1
-    ) {
+    ): Boolean = keyPressOnPhysicalDevice(rule, keyCode, InputDevice.SOURCE_DPAD, count)
+
+    private fun keyPressOnPhysicalDevice(
+        rule: ComposeContentTestRule,
+        keyCode: Int,
+        source: Int,
+        count: Int = 1,
+        metaState: Int = 0,
+    ): Boolean {
+        val deviceId = InputDevice.getDeviceIds().firstOrNull { id ->
+            InputDevice.getDevice(id)?.isVirtual?.not() ?: false &&
+                InputDevice.getDevice(id)?.supportsSource(source) ?: false
+        } ?: return false
+        val keyEventDown = KeyEvent(
+            SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
+            KeyEvent.ACTION_DOWN, keyCode, 0, metaState,
+            deviceId, 0, 0, InputDevice.SOURCE_DPAD
+        )
+        val keyEventUp = KeyEvent(
+            SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
+            KeyEvent.ACTION_UP, keyCode, 0, metaState,
+            deviceId, 0, 0, InputDevice.SOURCE_DPAD
+        )
+
         repeat(count) {
-            val deviceId = InputDevice.getDeviceIds().first { id ->
-                InputDevice.getDevice(id)?.isVirtual?.not() ?: false
-            }
-            val keyEventDown = KeyEvent(
-                SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
-                KeyEvent.ACTION_DOWN, keyCode, 0, 0, deviceId, 0
-            )
             rule.onRoot().performKeyPress(androidx.compose.ui.input.key.KeyEvent(keyEventDown))
             rule.waitForIdle()
-            val keyEventUp = KeyEvent(
-                SystemClock.uptimeMillis(), SystemClock.uptimeMillis(),
-                KeyEvent.ACTION_UP, keyCode, 0, 0, deviceId, 0
-            )
             rule.onRoot().performKeyPress(androidx.compose.ui.input.key.KeyEvent(keyEventUp))
         }
+        return true
     }
 
     // Triggers a key press on the virtual keyboard
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldKeyEventTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldKeyEventTest.kt
index 735e1e755..f39ba5e 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldKeyEventTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldKeyEventTest.kt
@@ -23,11 +23,13 @@
 import androidx.compose.foundation.text2.input.TextFieldLineLimits.MultiLine
 import androidx.compose.foundation.text2.input.TextFieldLineLimits.SingleLine
 import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.selection.FakeClipboardManager
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.platform.ClipboardManager
 import androidx.compose.ui.platform.LocalClipboardManager
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.testTag
@@ -48,7 +50,6 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import com.google.common.truth.Truth.assertThat
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -76,7 +77,6 @@
         }
     }
 
-    @Ignore // re-enable after copy-cut-paste is supported
     @Test
     fun textField_copyPaste() {
         keysSequenceTest("hello") {
@@ -91,7 +91,19 @@
         }
     }
 
-    @Ignore // re-enable after copy-cut-paste is supported
+    @Test
+    fun secureTextField_doesNotAllowCopy() {
+        keysSequenceTest("hello", secure = true) {
+            clipboardManager.setText(AnnotatedString("world"))
+            withKeyDown(Key.CtrlLeft) {
+                pressKey(Key.A)
+                pressKey(Key.C)
+            }
+            pressKey(Key.Copy) // also attempt direct copy
+            expectedClipboardText("world")
+        }
+    }
+
     @Test
     fun textField_directCopyPaste() {
         keysSequenceTest("hello") {
@@ -105,7 +117,6 @@
         }
     }
 
-    @Ignore // re-enable after copy-cut-paste is supported
     @Test
     fun textField_directCutPaste() {
         keysSequenceTest("hello") {
@@ -118,6 +129,20 @@
     }
 
     @Test
+    fun secureTextField_doesNotAllowCut() {
+        keysSequenceTest("hello", secure = true) {
+            clipboardManager.setText(AnnotatedString("world"))
+            withKeyDown(Key.CtrlLeft) {
+                pressKey(Key.A)
+                pressKey(Key.X)
+            }
+            pressKey(Key.Cut) // Also attempts direct cut
+            expectedText("hello")
+            expectedClipboardText("world")
+        }
+    }
+
+    @Test
     fun textField_linesNavigation() {
         keysSequenceTest("hello\nworld") {
             pressKey(Key.DirectionDown)
@@ -247,7 +272,6 @@
         }
     }
 
-    @Ignore // TODO(halilibo): Remove ignore when backing buffer supports reversed selection
     @Test
     fun textField_lineEndStart() {
         keysSequenceTest(initText = "hi\nhello world\nhi") {
@@ -268,7 +292,6 @@
         }
     }
 
-    @Ignore // TODO(halilibo): Remove ignore when backing buffer supports reversed selection
     @Test
     fun textField_altLineLeftRight() {
         keysSequenceTest(initText = "hi\nhello world\nhi") {
@@ -289,7 +312,6 @@
         }
     }
 
-    @Ignore // TODO(halilibo): Remove ignore when backing buffer supports reversed selection
     @Test
     fun textField_altTop() {
         keysSequenceTest(initText = "hi\nhello world\nhi") {
@@ -414,7 +436,6 @@
         }
     }
 
-    @Ignore // TODO(halilibo): Remove ignore when backing buffer supports reversed selection
     @Test
     fun textField_selectionCaret() {
         keysSequenceTest("hello world") {
@@ -427,7 +448,7 @@
             press(Key.CtrlLeft + Key.ShiftLeft + Key.DirectionLeft)
             expectedSelection(TextRange(6, 0))
             press(Key.ShiftLeft + Key.DirectionRight)
-            expectedSelection(TextRange(1, 6))
+            expectedSelection(TextRange(6, 1))
         }
     }
 
@@ -555,7 +576,6 @@
         }
     }
 
-    @Ignore // TODO(halilibo): Remove ignore when backing buffer supports reversed selection
     @Test
     fun textField_selectToLeft() {
         keysSequenceTest("hello world hello") {
@@ -570,7 +590,6 @@
         }
     }
 
-    @Ignore // b/293919923
     @Test
     fun textField_withActiveSelection_shiftTabSingleLine() {
         keysSequenceTest("text", singleLine = true) {
@@ -612,6 +631,7 @@
 
     private inner class SequenceScope(
         val state: TextFieldState,
+        val clipboardManager: ClipboardManager,
         private val keyInjectionScope: KeyInjectionScope
     ) : KeyInjectionScope by keyInjectionScope {
 
@@ -639,6 +659,12 @@
                 assertThat(state.text.selectionInChars).isEqualTo(selection)
             }
         }
+
+        fun expectedClipboardText(text: String) {
+            rule.runOnIdle {
+                assertThat(clipboardManager.getText()?.text).isEqualTo(text)
+            }
+        }
     }
 
     private fun keysSequenceTest(
@@ -646,24 +672,41 @@
         initSelection: TextRange = TextRange.Zero,
         modifier: Modifier = Modifier.fillMaxSize(),
         singleLine: Boolean = false,
+        secure: Boolean = false,
         sequence: SequenceScope.() -> Unit,
     ) {
         val state = TextFieldState(initText, initSelection)
         val focusRequester = FocusRequester()
+        val clipboardManager = FakeClipboardManager("InitialTestText")
         rule.setContent {
-            LocalClipboardManager.current.setText(AnnotatedString("InitialTestText"))
-            CompositionLocalProvider(LocalDensity provides defaultDensity) {
-                BasicTextField2(
-                    state = state,
-                    textStyle = TextStyle(
-                        fontFamily = TEST_FONT_FAMILY,
-                        fontSize = 30.sp
-                    ),
-                    modifier = modifier
-                        .focusRequester(focusRequester)
-                        .testTag(tag),
-                    lineLimits = if (singleLine) SingleLine else MultiLine(),
-                )
+            CompositionLocalProvider(
+                LocalDensity provides defaultDensity,
+                LocalClipboardManager provides clipboardManager,
+            ) {
+                if (!secure) {
+                    BasicTextField2(
+                        state = state,
+                        textStyle = TextStyle(
+                            fontFamily = TEST_FONT_FAMILY,
+                            fontSize = 30.sp
+                        ),
+                        modifier = modifier
+                            .focusRequester(focusRequester)
+                            .testTag(tag),
+                        lineLimits = if (singleLine) SingleLine else MultiLine(),
+                    )
+                } else {
+                    BasicSecureTextField(
+                        state = state,
+                        textStyle = TextStyle(
+                            fontFamily = TEST_FONT_FAMILY,
+                            fontSize = 30.sp
+                        ),
+                        modifier = modifier
+                            .focusRequester(focusRequester)
+                            .testTag(tag)
+                    )
+                }
             }
         }
 
@@ -673,7 +716,7 @@
         rule.mainClock.advanceTimeBy(1000)
 
         rule.onNodeWithTag(tag).performKeyInput {
-            sequence(SequenceScope(state, this@performKeyInput))
+            sequence(SequenceScope(state, clipboardManager, this@performKeyInput))
         }
     }
 }
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldScrollTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldScrollTest.kt
index 0e906f5..61a58993 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldScrollTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/TextFieldScrollTest.kt
@@ -34,6 +34,7 @@
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -586,14 +587,17 @@
         scrollState: ScrollState,
         lineLimits: TextFieldLineLimits
     ) {
-        val textLayoutResultRef: Ref<TextLayoutResultProxy?> = remember { Ref() }
+        val textLayoutResultRef: Ref<State<TextLayoutResultProxy?>?> = remember { Ref() }
 
         testScope = rememberCoroutineScope()
         BasicTextField2(
             state = state,
             scrollState = scrollState,
             onTextLayout = {
-                textLayoutResultRef.value = TextLayoutResultProxy(it)
+                textLayoutResultRef.value = object : State<TextLayoutResultProxy?> {
+                    override val value: TextLayoutResultProxy?
+                        get() = it()?.let(::TextLayoutResultProxy)
+                }
             },
             lineLimits = lineLimits,
             modifier = modifier.testTag(TextfieldTag)
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCacheTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCacheTest.kt
new file mode 100644
index 0000000..58c19ca
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCacheTest.kt
@@ -0,0 +1,591 @@
+/*
+ * 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.internal
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text2.input.CodepointTransformation
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.runtime.snapshots.SnapshotStateObserver
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.createFontFamilyResolver
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.sp
+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.test.assertNotNull
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalFoundationApi::class)
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class TextFieldLayoutStateCacheTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var textFieldState = TextFieldState()
+    private var codepointTransformation: CodepointTransformation? = null
+    private var textStyle = TextStyle()
+    private var singleLine = false
+    private var softWrap = false
+    private var cache = TextFieldLayoutStateCache()
+    private var density = Density(1f, 1f)
+    private var layoutDirection = LayoutDirection.Ltr
+    private var fontFamilyResolver =
+        createFontFamilyResolver(InstrumentationRegistry.getInstrumentation().context)
+    private var constraints = Constraints()
+
+    @Test
+    fun updateAllInputs_doesntInvalidateSnapshot_whenNothingChanged() {
+        assertInvalidationsOnChange(0) {
+            updateNonMeasureInputs()
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenTextContentChanged() {
+        textFieldState.edit {
+            replace(0, length, "")
+            placeCursorBeforeCharAt(0)
+        }
+        assertInvalidationsOnChange(1) {
+            textFieldState.edit {
+                append("hello")
+                placeCursorBeforeCharAt(0)
+            }
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenTextSelectionChanged() {
+        textFieldState.edit {
+            append("hello")
+            placeCursorBeforeCharAt(0)
+        }
+        assertInvalidationsOnChange(1) {
+            textFieldState.edit {
+                placeCursorBeforeCharAt(1)
+            }
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenCodepointTransformationChanged() {
+        assertInvalidationsOnChange(1) {
+            codepointTransformation = CodepointTransformation { _, codepoint -> codepoint }
+            updateNonMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenStyleLayoutAffectingAttrsChanged() {
+        textStyle = TextStyle(fontSize = 12.sp)
+        assertInvalidationsOnChange(1) {
+            textStyle = TextStyle(fontSize = 23.sp)
+            updateNonMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_doesntInvalidateSnapshot_whenStyleDrawAffectingAttrsChanged() {
+        textStyle = TextStyle(color = Color.Black)
+        assertInvalidationsOnChange(0) {
+            textStyle = TextStyle(color = Color.Blue)
+            updateNonMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenSingleLineChanged() {
+        assertInvalidationsOnChange(1) {
+            singleLine = !singleLine
+            updateNonMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateNonMeasureInputs_invalidatesSnapshot_whenSoftWrapChanged() {
+        assertInvalidationsOnChange(1) {
+            softWrap = !softWrap
+            updateNonMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenDensityInstanceChangedWithDifferentValues() {
+        density = Density(1f, 1f)
+        assertInvalidationsOnChange(1) {
+            density = Density(1f, 2f)
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_doesntInvalidateSnapshot_whenDensityInstanceChangedWithSameValues() {
+        density = Density(1f, 1f)
+        assertInvalidationsOnChange(0) {
+            density = Density(1f, 1f)
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenDensityValueChangedWithSameInstance() {
+        var densityValue = 1f
+        density = object : Density {
+            override val density: Float
+                get() = densityValue
+            override val fontScale: Float = 1f
+        }
+        assertInvalidationsOnChange(1) {
+            densityValue = 2f
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenFontScaleChangedWithSameInstance() {
+        var fontScale = 1f
+        density = object : Density {
+            override val density: Float = 1f
+            override val fontScale: Float
+                get() = fontScale
+        }
+        assertInvalidationsOnChange(1) {
+            fontScale = 2f
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenLayoutDirectionChanged() {
+        layoutDirection = LayoutDirection.Ltr
+        assertInvalidationsOnChange(1) {
+            layoutDirection = LayoutDirection.Rtl
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenFontFamilyResolverChanged() {
+        assertInvalidationsOnChange(1) {
+            fontFamilyResolver =
+                createFontFamilyResolver(InstrumentationRegistry.getInstrumentation().context)
+            updateMeasureInputs()
+        }
+    }
+
+    @Ignore("b/294443266: figure out how to make fonts stale for test")
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenFontFamilyResolverFontChanged() {
+        fontFamilyResolver =
+            createFontFamilyResolver(InstrumentationRegistry.getInstrumentation().context)
+        assertInvalidationsOnChange(1) {
+            TODO("b/294443266: make fonts stale")
+        }
+    }
+
+    @Test
+    fun updateMeasureInputs_invalidatesSnapshot_whenConstraintsChanged() {
+        constraints = Constraints.fixed(5, 5)
+        assertInvalidationsOnChange(1) {
+            constraints = Constraints.fixed(6, 5)
+            updateMeasureInputs()
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenTextContentsChanged() {
+        textFieldState.edit {
+            replace(0, length, "h")
+            placeCursorBeforeCharAt(0)
+        }
+        assertLayoutChange(
+            change = {
+                textFieldState.edit {
+                    replace(0, length, "hello")
+                    placeCursorBeforeCharAt(0)
+                }
+            },
+        ) { old, new ->
+            assertThat(old.layoutInput.text.text).isEqualTo("h")
+            assertThat(new.layoutInput.text.text).isEqualTo("hello")
+        }
+    }
+
+    @Test
+    fun value_returnsCachedLayout_whenTextSelectionChanged() {
+        textFieldState.edit {
+            replace(0, length, "hello")
+            placeCursorBeforeCharAt(0)
+        }
+        assertLayoutChange(
+            change = {
+                textFieldState.edit {
+                    placeCursorBeforeCharAt(1)
+                }
+            }
+        ) { old, new ->
+            assertThat(new).isSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenCodepointTransformationInstanceChangedWithDifferentOutput() {
+        textFieldState.setTextAndPlaceCursorAtEnd("h")
+        codepointTransformation = CodepointTransformation { _, codepoint -> codepoint }
+        assertLayoutChange(
+            change = {
+                codepointTransformation = CodepointTransformation { _, codepoint -> codepoint + 1 }
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(old.layoutInput.text.text).isEqualTo("h")
+            assertThat(new.layoutInput.text.text).isEqualTo("i")
+        }
+    }
+
+    @Test
+    fun value_returnsCachedLayout_whenCodepointTransformationInstanceChangedWithSameOutput() {
+        textFieldState.setTextAndPlaceCursorAtEnd("h")
+        codepointTransformation = CodepointTransformation { _, codepoint -> codepoint }
+        assertLayoutChange(
+            change = {
+                codepointTransformation = CodepointTransformation { _, codepoint -> codepoint }
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenStyleLayoutAffectingAttributesChanged() {
+        textStyle = TextStyle(fontSize = 12.sp)
+        assertLayoutChange(
+            change = {
+                textStyle = TextStyle(fontSize = 23.sp)
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(old.layoutInput.style.fontSize).isEqualTo(12.sp)
+            assertThat(new.layoutInput.style.fontSize).isEqualTo(23.sp)
+        }
+    }
+
+    @Test
+    fun value_returnsCachedLayout_whenStyleDrawAffectingAttributesChanged() {
+        textStyle = TextStyle(color = Color.Black)
+        assertLayoutChange(
+            change = {
+                textStyle = TextStyle(color = Color.Blue)
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenSingleLineChanged() {
+        assertLayoutChange(
+            change = {
+                singleLine = !singleLine
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isNotSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenSoftWrapChanged() {
+        assertLayoutChange(
+            change = {
+                softWrap = !softWrap
+                updateNonMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(old.layoutInput.softWrap).isEqualTo(!softWrap)
+            assertThat(new.layoutInput.softWrap).isEqualTo(softWrap)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenDensityValueChangedWithSameInstance() {
+        var densityValue = 1f
+        density = object : Density {
+            override val density: Float
+                get() = densityValue
+            override val fontScale: Float = 1f
+        }
+        assertLayoutChange(
+            change = {
+                densityValue = 2f
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isNotSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenFontScaleChangedWithSameInstance() {
+        var fontScale = 1f
+        density = object : Density {
+            override val density: Float = 1f
+            override val fontScale: Float
+                get() = fontScale
+        }
+        assertLayoutChange(
+            change = {
+                fontScale = 2f
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isNotSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsCachedLayout_whenDensityInstanceChangedWithSameValues() {
+        density = Density(1f, 1f)
+        assertLayoutChange(
+            change = {
+                density = Density(1f, 1f)
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenLayoutDirectionChanged() {
+        layoutDirection = LayoutDirection.Ltr
+        assertLayoutChange(
+            change = {
+                layoutDirection = LayoutDirection.Rtl
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(old.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Ltr)
+            assertThat(new.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Rtl)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenFontFamilyResolverChanged() {
+        assertLayoutChange(
+            change = {
+                fontFamilyResolver =
+                    createFontFamilyResolver(InstrumentationRegistry.getInstrumentation().context)
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(new).isNotSameInstanceAs(old)
+        }
+    }
+
+    @Ignore("b/294443266: figure out how to make fonts stale for test")
+    @Test
+    fun value_returnsNewLayout_whenFontFamilyResolverFontChanged() {
+        fontFamilyResolver =
+            createFontFamilyResolver(InstrumentationRegistry.getInstrumentation().context)
+        assertLayoutChange(
+            change = {
+                TODO("b/294443266: make fonts stale")
+            }
+        ) { old, new ->
+            assertThat(new).isNotSameInstanceAs(old)
+        }
+    }
+
+    @Test
+    fun value_returnsNewLayout_whenConstraintsChanged() {
+        constraints = Constraints.fixed(5, 5)
+        assertLayoutChange(
+            change = {
+                constraints = Constraints.fixed(6, 5)
+                updateMeasureInputs()
+            }
+        ) { old, new ->
+            assertThat(old.layoutInput.constraints).isEqualTo(Constraints.fixed(5, 5))
+            assertThat(new.layoutInput.constraints).isEqualTo(Constraints.fixed(6, 5))
+        }
+    }
+
+    @Test
+    fun cacheUpdateInSnapshot_onlyVisibleToParentSnapshotAfterApply() {
+        layoutDirection = LayoutDirection.Ltr
+        updateNonMeasureInputs()
+        updateMeasureInputs()
+        val initialLayout = cache.value!!
+        val snapshot = Snapshot.takeMutableSnapshot()
+
+        try {
+            snapshot.enter {
+                layoutDirection = LayoutDirection.Rtl
+                updateMeasureInputs()
+
+                val newLayout = cache.value!!
+                assertThat(initialLayout.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Ltr)
+                assertThat(newLayout.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Rtl)
+                assertThat(cache.value!!).isSameInstanceAs(newLayout)
+            }
+
+            // Not visible in parent yet.
+            assertThat(initialLayout.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Ltr)
+            assertThat(cache.value!!).isSameInstanceAs(initialLayout)
+            snapshot.apply().check()
+
+            // Now visible in parent.
+            val newLayout = cache.value!!
+            assertThat(initialLayout.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Ltr)
+            assertThat(newLayout.layoutInput.layoutDirection).isEqualTo(LayoutDirection.Rtl)
+            assertThat(cache.value!!).isSameInstanceAs(newLayout)
+        } finally {
+            snapshot.dispose()
+        }
+    }
+
+    @Test
+    fun cachedValue_recomputed_afterSnapshotWithConflictingInputsApplied() {
+        softWrap = false
+        layoutDirection = LayoutDirection.Ltr
+        updateNonMeasureInputs()
+        updateMeasureInputs()
+        val snapshot = Snapshot.takeMutableSnapshot()
+
+        try {
+            softWrap = true
+            updateNonMeasureInputs()
+            val initialLayout = cache.value!!
+
+            snapshot.enter {
+                layoutDirection = LayoutDirection.Rtl
+                updateMeasureInputs()
+                with(cache.value!!) {
+                    assertThat(layoutInput.softWrap).isEqualTo(false)
+                    assertThat(layoutInput.layoutDirection).isEqualTo(LayoutDirection.Rtl)
+                    assertThat(cache.value!!).isSameInstanceAs(this)
+                }
+            }
+
+            // Parent only sees its update.
+            with(cache.value!!) {
+                assertThat(layoutInput.softWrap).isEqualTo(true)
+                assertThat(layoutInput.layoutDirection).isEqualTo(LayoutDirection.Ltr)
+                assertThat(this).isSameInstanceAs(initialLayout)
+                assertThat(cache.value!!).isSameInstanceAs(this)
+            }
+            snapshot.apply().check()
+
+            // Cache should now reflect merged inputs.
+            with(cache.value!!) {
+                assertThat(layoutInput.softWrap).isEqualTo(true)
+                assertThat(layoutInput.layoutDirection).isEqualTo(LayoutDirection.Rtl)
+                assertThat(cache.value!!).isSameInstanceAs(this)
+            }
+        } finally {
+            snapshot.dispose()
+        }
+    }
+
+    private fun assertLayoutChange(
+        change: () -> Unit,
+        compare: (old: TextLayoutResult, new: TextLayoutResult) -> Unit
+    ) {
+        updateNonMeasureInputs()
+        updateMeasureInputs()
+        val initialLayout = cache.value
+
+        change()
+        val newLayout = cache.value
+
+        assertNotNull(newLayout)
+        assertNotNull(initialLayout)
+        compare(initialLayout, newLayout)
+    }
+
+    private fun assertInvalidationsOnChange(
+        expectedInvalidations: Int,
+        update: () -> Unit,
+    ) {
+        updateNonMeasureInputs()
+        updateMeasureInputs()
+        var invalidations = 0
+
+        observingLayoutCache({ invalidations++ }) {
+            update()
+        }
+
+        assertThat(invalidations).isEqualTo(expectedInvalidations)
+    }
+
+    private fun updateNonMeasureInputs() {
+        cache.updateNonMeasureInputs(
+            textFieldState = textFieldState,
+            codepointTransformation = codepointTransformation,
+            textStyle = textStyle,
+            singleLine = singleLine,
+            softWrap = softWrap
+        )
+    }
+
+    private fun updateMeasureInputs() {
+        cache.layoutWithNewMeasureInputs(
+            density = density,
+            layoutDirection = layoutDirection,
+            fontFamilyResolver = fontFamilyResolver,
+            constraints = constraints
+        )
+    }
+
+    private fun observingLayoutCache(
+        onLayoutStateInvalidated: (TextLayoutResult?) -> Unit,
+        block: () -> Unit
+    ) {
+        val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver {
+            Snapshot.sendApplyNotifications()
+        }
+        val observer = SnapshotStateObserver(onChangedExecutor = { it() })
+        observer.start()
+        try {
+            observer.observeReads(Unit, onValueChangedForScope = {
+                onLayoutStateInvalidated(cache.value)
+            }) { cache.value }
+            block()
+        } finally {
+            observer.stop()
+            globalWriteObserverHandle.dispose()
+        }
+    }
+}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongClickTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongClickTest.kt
deleted file mode 100644
index 0058b39..0000000
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongClickTest.kt
+++ /dev/null
@@ -1,199 +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.compose.foundation.text2.selection
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.ScrollState
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.Handle
-import androidx.compose.foundation.text.TEST_FONT_FAMILY
-import androidx.compose.foundation.text.selection.isSelectionHandle
-import androidx.compose.foundation.text2.BasicTextField2
-import androidx.compose.foundation.text2.input.TextFieldLineLimits
-import androidx.compose.foundation.text2.input.TextFieldState
-import androidx.compose.foundation.text2.input.rememberTextFieldState
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.longClick
-import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.test.performTouchInput
-import androidx.compose.ui.text.TextRange
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.test.filters.LargeTest
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import org.junit.Rule
-import org.junit.Test
-
-/**
- * Tests for long click interactions on BasicTextField2.
- */
-@OptIn(ExperimentalFoundationApi::class)
-@LargeTest
-class TextFieldLongClickTest {
-
-    @get:Rule
-    val rule = createComposeRule()
-
-    private val TAG = "BasicTextField2"
-
-    private val fontSize = 10.sp
-
-    private val defaultTextStyle = TextStyle(fontFamily = TEST_FONT_FAMILY, fontSize = fontSize)
-
-    @Test
-    fun emptyTextField_longClickDoesNotShowCursor() {
-        rule.setContent {
-            BasicTextField2(
-                state = rememberTextFieldState(),
-                textStyle = defaultTextStyle,
-                modifier = Modifier.testTag(TAG)
-            )
-        }
-
-        rule.onNodeWithTag(TAG).performTouchInput { longClick() }
-
-        rule.onNode(isSelectionHandle(Handle.Cursor)).assertDoesNotExist()
-    }
-
-    @Test
-    fun longClickOnEmptyRegion_showsCursorAtTheEnd() {
-        val state = TextFieldState("abc")
-        rule.setContent {
-            BasicTextField2(
-                state = state,
-                textStyle = defaultTextStyle,
-                modifier = Modifier
-                    .testTag(TAG)
-                    .width(100.dp)
-            )
-        }
-
-        rule.onNodeWithTag(TAG).performTouchInput {
-            longClick(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2))
-        }
-
-        rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsDisplayed()
-        assertThat(state.text.selectionInChars).isEqualTo(TextRange(3))
-    }
-
-    @Test
-    fun longClickOnWord_selectsWord() {
-        val state = TextFieldState("abc def ghi")
-        rule.setContent {
-            BasicTextField2(
-                state = state,
-                textStyle = defaultTextStyle,
-                modifier = Modifier.testTag(TAG)
-            )
-        }
-
-        rule.onNodeWithTag(TAG).performTouchInput {
-            longClick(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2))
-        }
-
-        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
-        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
-    }
-
-    @Test
-    fun longClickOnWhitespace_doesNotSelectWhitespace() {
-        val state = TextFieldState("abc def ghi")
-        rule.setContent {
-            BasicTextField2(
-                state = state,
-                textStyle = defaultTextStyle,
-                modifier = Modifier.testTag(TAG)
-            )
-        }
-
-        rule.onNodeWithTag(TAG).performTouchInput {
-            longClick(Offset(fontSize.toPx() * 7.5f, fontSize.toPx() / 2))
-        }
-
-        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
-        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selectionInChars).isNotEqualTo(TextRange(7, 8))
-        assertThat(state.text.selectionInChars.collapsed).isFalse()
-    }
-
-    @Test
-    fun longClickOnScrolledTextField_selectsWord() {
-        val state = TextFieldState("abc def ghi abc def ghi")
-        val scrollState = ScrollState(0)
-        lateinit var scope: CoroutineScope
-        rule.setContent {
-            scope = rememberCoroutineScope()
-            BasicTextField2(
-                state = state,
-                textStyle = defaultTextStyle,
-                scrollState = scrollState,
-                lineLimits = TextFieldLineLimits.SingleLine,
-                modifier = Modifier
-                    .testTag(TAG)
-                    .width(30.dp)
-            )
-        }
-
-        assertThat(scrollState.maxValue).isGreaterThan(0)
-        scope.launch { scrollState.scrollTo(scrollState.maxValue) }
-
-        rule.onNodeWithTag(TAG).performTouchInput { longClick(centerRight) }
-
-        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
-        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selectionInChars).isEqualTo(TextRange(20, 23))
-    }
-
-    @Test
-    fun longClickOnDecoratedTextField_selectsWord() {
-        val state = TextFieldState("abc def ghi")
-        rule.setContent {
-            BasicTextField2(
-                state = state,
-                textStyle = defaultTextStyle,
-                modifier = Modifier.testTag(TAG),
-                decorationBox = {
-                    Box(modifier = Modifier.padding(32.dp)) {
-                        it()
-                    }
-                }
-            )
-        }
-
-        rule.onNodeWithTag(TAG).performTouchInput {
-            longClick(Offset(
-                x = 32.dp.toPx() + fontSize.toPx() * 5f,
-                y = 32.dp.toPx() + fontSize.toPx() / 2
-            ))
-        }
-
-        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
-        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
-    }
-}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongPressTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongPressTest.kt
new file mode 100644
index 0000000..32121cb
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldLongPressTest.kt
@@ -0,0 +1,478 @@
+/*
+ * 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.selection
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.Handle
+import androidx.compose.foundation.text.TEST_FONT_FAMILY
+import androidx.compose.foundation.text.selection.FakeTextToolbar
+import androidx.compose.foundation.text.selection.gestures.util.longPress
+import androidx.compose.foundation.text.selection.isSelectionHandle
+import androidx.compose.foundation.text2.BasicTextField2
+import androidx.compose.foundation.text2.input.TextFieldLineLimits
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.input.rememberTextFieldState
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalTextToolbar
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * Tests for long click interactions on BasicTextField2.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@LargeTest
+class TextFieldLongPressTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val TAG = "BasicTextField2"
+
+    private val fontSize = 10.sp
+
+    private val defaultTextStyle = TextStyle(fontFamily = TEST_FONT_FAMILY, fontSize = fontSize)
+
+    @Test
+    fun emptyTextField_longPressDoesNotShowCursor() {
+        rule.setContent {
+            BasicTextField2(
+                state = rememberTextFieldState(),
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput { longClick() }
+
+        rule.onNode(isSelectionHandle(Handle.Cursor)).assertDoesNotExist()
+    }
+
+    @Test
+    fun longPress_requestsFocus_beforePointerIsReleased() {
+        val state = TextFieldState("Hello, World!")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(center)
+        }
+
+        rule.onNodeWithTag(TAG).assertIsFocused()
+        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
+        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
+    }
+
+    @Test
+    fun longPressOnEmptyRegion_showsCursorAtTheEnd() {
+        val state = TextFieldState("abc")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier
+                    .testTag(TAG)
+                    .width(100.dp)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longClick(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2))
+        }
+
+        rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsDisplayed()
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(3))
+    }
+
+    @Test
+    fun longPressOnEmptyRegion_showsTextToolbar() {
+        val state = TextFieldState("abc")
+        var showMenuCalled = 0
+        val textToolbar = FakeTextToolbar(
+            onShowMenu = { _, _, _, _, _ ->
+                showMenuCalled++
+            }, onHideMenu = {}
+        )
+        val clipboardManager = FakeClipboardManager("hello")
+        rule.setContent {
+            CompositionLocalProvider(
+                LocalTextToolbar provides textToolbar,
+                LocalClipboardManager provides clipboardManager
+            ) {
+                BasicTextField2(
+                    state = state,
+                    textStyle = defaultTextStyle,
+                    modifier = Modifier
+                        .testTag(TAG)
+                        .width(100.dp)
+                )
+            }
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longClick(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2))
+        }
+
+        rule.runOnIdle {
+            assertThat(showMenuCalled).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun longPressOnWord_selectsWord() {
+        val state = TextFieldState("abc def ghi")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longClick(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2))
+        }
+
+        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
+        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
+    }
+
+    @Test
+    fun longPressOnWhitespace_doesNotSelectWhitespace() {
+        val state = TextFieldState("abc def ghi")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longClick(Offset(fontSize.toPx() * 7.5f, fontSize.toPx() / 2))
+        }
+
+        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
+        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
+        assertThat(state.text.selectionInChars).isNotEqualTo(TextRange(7, 8))
+        assertThat(state.text.selectionInChars.collapsed).isFalse()
+    }
+
+    @Test
+    fun longPressOnScrolledTextField_selectsWord() {
+        val state = TextFieldState("abc def ghi abc def ghi")
+        val scrollState = ScrollState(0)
+        lateinit var scope: CoroutineScope
+        rule.setContent {
+            scope = rememberCoroutineScope()
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                scrollState = scrollState,
+                lineLimits = TextFieldLineLimits.SingleLine,
+                modifier = Modifier
+                    .testTag(TAG)
+                    .width(30.dp)
+            )
+        }
+
+        assertThat(scrollState.maxValue).isGreaterThan(0)
+        scope.launch { scrollState.scrollTo(scrollState.maxValue) }
+
+        rule.onNodeWithTag(TAG).performTouchInput { longClick(centerRight) }
+
+        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
+        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(20, 23))
+    }
+
+    @Test
+    fun longPressOnDecoratedTextField_selectsWord() {
+        val state = TextFieldState("abc def ghi")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG),
+                decorationBox = {
+                    Box(modifier = Modifier.padding(32.dp)) {
+                        it()
+                    }
+                }
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longClick(
+                Offset(
+                    x = 32.dp.toPx() + fontSize.toPx() * 5f,
+                    y = 32.dp.toPx() + fontSize.toPx() / 2
+                )
+            )
+        }
+
+        rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
+        rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
+    }
+
+    @Test
+    fun longPress_dragToRight_selectsCurrentAndNextWord_ltr() {
+        val state = TextFieldState("abc def ghi")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(fontSize.toPx() * 3f, 0f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 11))
+    }
+
+    @Test
+    fun longPress_dragToLeft_selectsCurrentAndPreviousWord_ltr() {
+        val state = TextFieldState("abc def ghi")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(-fontSize.toPx() * 3f, 0f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(0, 7))
+    }
+
+    @Test
+    fun longPress_dragDown_selectsFromCurrentToTargetWord_ltr() {
+        val state = TextFieldState("abc def\nabc def\nabc def")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(0f, fontSize.toPx()))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 15))
+    }
+
+    @Test
+    fun longPress_dragUp_selectsFromCurrentToTargetWord_ltr() {
+        val state = TextFieldState("abc def\nabc def\nabc def")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() * 3 / 2)) // second line, def
+            moveBy(Offset(0f, -fontSize.toPx()))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 15))
+    }
+
+    @Test
+    fun longPress_startingFromEndPadding_dragToLeft_selectsLastWord_ltr() {
+        val state = TextFieldState("abc def")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier
+                    .testTag(TAG)
+                    .width(100.dp)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(centerRight)
+            moveTo(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
+    }
+
+    //region RTL
+
+    @Test
+    fun longPress_dragToRight_selectsCurrentAndPreviousWord_rtl() {
+        val state = TextFieldState(rtlText3)
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(fontSize.toPx() * 3f, 0f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(0, 7))
+    }
+
+    @Test
+    fun longPress_dragToLeft_selectsCurrentAndNextWord_rtl() {
+        val state = TextFieldState(rtlText3)
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(-fontSize.toPx() * 3f, 0f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 11))
+    }
+
+    @Test
+    fun longPress_dragDown_selectsFromCurrentToTargetWord_rtl() {
+        val state = TextFieldState("$rtlText2\n$rtlText2\n$rtlText2")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() / 2))
+            moveBy(Offset(0f, fontSize.toPx()))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(0, 11))
+    }
+
+    @Test
+    fun longPress_dragUp_selectsFromCurrentToTargetWord_rtl() {
+        val state = TextFieldState("$rtlText2\n$rtlText2\n$rtlText2")
+        rule.setContent {
+            BasicTextField2(
+                state = state,
+                textStyle = defaultTextStyle,
+                modifier = Modifier.testTag(TAG)
+            )
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(Offset(fontSize.toPx() * 5f, fontSize.toPx() * 3 / 2))
+            moveBy(Offset(0f, -fontSize.toPx()))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(0, 11))
+    }
+
+    @Test
+    fun longPress_startingFromEndPadding_dragToRight_selectsLastWord_rtl() {
+        val state = TextFieldState(rtlText2)
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+                BasicTextField2(
+                    state = state,
+                    textStyle = defaultTextStyle,
+                    modifier = Modifier
+                        .testTag(TAG)
+                        .width(100.dp)
+                )
+            }
+        }
+
+        rule.onNodeWithTag(TAG).performTouchInput {
+            longPress(centerLeft)
+            moveTo(Offset(fontSize.toPx() * 5, fontSize.toPx() / 2f))
+            up()
+        }
+
+        assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 7))
+    }
+
+    //endregion
+
+    companion object {
+        private const val rtlText2 = "\u05D0\u05D1\u05D2 \u05D3\u05D4\u05D5"
+        private const val rtlText3 = "\u05D0\u05D1\u05D2 \u05D3\u05D4\u05D5 \u05D6\u05D7\u05D8"
+    }
+}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifierTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifierTest.kt
index 1454801..426f17c 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifierTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifierTest.kt
@@ -17,18 +17,39 @@
 package androidx.compose.foundation.text2.selection
 
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.foundation.text.Handle
+import androidx.compose.foundation.text.TEST_FONT_FAMILY
 import androidx.compose.foundation.text.selection.AbstractSelectionMagnifierTests
+import androidx.compose.foundation.text.selection.getMagnifierCenterOffset
+import androidx.compose.foundation.text.selection.isSelectionHandle
 import androidx.compose.foundation.text2.BasicTextField2
+import androidx.compose.foundation.text2.input.TextFieldLineLimits
 import androidx.compose.foundation.text2.input.TextFieldState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInputSelection
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.toSize
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -51,7 +72,7 @@
             state = state,
             modifier = modifier,
             textStyle = style,
-            onTextLayout = { onTextLayout.invoke(it) }
+            onTextLayout = { it()?.let(onTextLayout) }
         )
     }
 
@@ -86,27 +107,96 @@
         checkMagnifierHiddenWhenDraggedTooFar(Handle.Cursor, checkStart = false)
     }
 
-    // TODO(halilibo): Re-enable when long press drag is supported
     @Test
-    override fun magnifier_appears_duringInitialLongPressDrag_expandingForwards() {
-        assert(true)
+    fun magnifier_staysAtVisibleRegion_whenCursorDraggedPastScrollThreshold_Ltr() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.Cursor, LayoutDirection.Ltr)
     }
 
-    // TODO(halilibo): Re-enable when long press drag is supported
     @Test
-    override fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards() {
-        assert(true)
+    fun magnifier_staysAtVisibleRegion_whenCursorDraggedPastScrollThreshold_Rtl() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.Cursor, LayoutDirection.Rtl)
     }
 
-    // TODO(halilibo): Re-enable when long press drag is supported
     @Test
-    override fun magnifier_appears_duringInitialLongPressDrag_expandingForwards_rtl() {
-        assert(true)
+    fun magnifier_staysAtVisibleRegion_whenSelectionStartDraggedPastScrollThreshold_Ltr() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.SelectionStart, LayoutDirection.Ltr)
     }
 
-    // TODO(halilibo): Re-enable when long press drag is supported
     @Test
-    override fun magnifier_appears_duringInitialLongPressDrag_expandingBackwards_rtl() {
-        assert(true)
+    fun magnifier_staysAtVisibleRegion_whenSelectionStartDraggedPastScrollThreshold_Rtl() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.SelectionStart, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun magnifier_staysAtVisibleRegion_whenSelectionEndDraggedPastScrollThreshold_Ltr() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.SelectionEnd, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun magnifier_staysAtVisibleRegion_whenSelectionEndDraggedPastScrollThreshold_Rtl() {
+        checkMagnifierStayAtEndWhenDraggedBeyondScroll(Handle.SelectionEnd, LayoutDirection.Rtl)
+    }
+
+    @OptIn(ExperimentalTestApi::class, ExperimentalFoundationApi::class)
+    private fun checkMagnifierStayAtEndWhenDraggedBeyondScroll(
+        handle: Handle,
+        layoutDirection: LayoutDirection = LayoutDirection.Ltr
+    ) {
+        var screenSize = Size.Zero
+        val dragDirection = if (layoutDirection == LayoutDirection.Rtl) -1f else 1f
+        val directionVector = Offset(1f, 0f) * dragDirection
+        val fillerWord = if (layoutDirection == LayoutDirection.Ltr)
+            "aaaa"
+        else
+            "\u05D0\u05D1\u05D2\u05D3"
+
+        val tag = "BasicTextField2"
+        val state = TextFieldState("$fillerWord $fillerWord $fillerWord ".repeat(10))
+
+        rule.setContent {
+            CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+                BasicTextField2(
+                    state = state,
+                    Modifier
+                        .fillMaxWidth()
+                        .onSizeChanged { screenSize = it.toSize() }
+                        .wrapContentSize()
+                        .testTag(tag),
+                    textStyle = TextStyle(fontFamily = TEST_FONT_FAMILY),
+                    lineLimits = TextFieldLineLimits.SingleLine
+                )
+            }
+        }
+
+        if (handle == Handle.Cursor) {
+            rule.onNodeWithTag(tag).performClick()
+        } else {
+            rule.onNodeWithTag(tag).performTextInputSelection(TextRange(5, 9))
+        }
+
+        // Touch and move the handle to show the magnifier.
+        rule.onNode(isSelectionHandle(handle)).performTouchInput {
+            down(center)
+            // If cursor, we have to drag the cursor to show the magnifier,
+            // press alone will not suffice
+            movePastSlopBy(directionVector)
+        }
+
+        val magnifierInitialPosition = getMagnifierCenterOffset(rule, requireSpecified = true)
+
+        // Drag all the way past the end of the line.
+        rule.onNode(isSelectionHandle(handle))
+            .performTouchInput {
+                val delta = Offset(
+                    x = screenSize.width * directionVector.x,
+                    y = screenSize.height * directionVector.y
+                )
+                moveBy(delta)
+            }
+
+        val x = if (layoutDirection == LayoutDirection.Ltr) screenSize.width else 0f
+        Truth.assertThat(getMagnifierCenterOffset(rule)).isEqualTo(
+            Offset(x, magnifierInitialPosition.y)
+        )
     }
 }
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
index f537273..8b53516 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
@@ -545,7 +545,7 @@
     @Test
     fun dragEndSelectionHandle_outOfBounds_vertically() {
         state = TextFieldState("abc def ".repeat(10), initialSelectionInChars = TextRange(0, 3))
-        lateinit var layoutResult: TextLayoutResult
+        lateinit var layoutResult: () -> TextLayoutResult?
         rule.setContent {
             BasicTextField2(
                 state,
@@ -561,8 +561,11 @@
         rule.waitForIdle()
         focusAndWait() // selection handles show up
 
-        swipeDown(Handle.SelectionEnd, layoutResult.size.height.toFloat())
-        swipeToRight(Handle.SelectionEnd, layoutResult.size.width.toFloat())
+        @Suppress("NAME_SHADOWING")
+        layoutResult()!!.let { layoutResult ->
+            swipeDown(Handle.SelectionEnd, layoutResult.size.height.toFloat())
+            swipeToRight(Handle.SelectionEnd, layoutResult.size.width.toFloat())
+        }
         rule.runOnIdle {
             assertThat(state.text.selectionInChars).isEqualTo(TextRange(0, 80))
         }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.android.kt
new file mode 100644
index 0000000..686c0c5
--- /dev/null
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.android.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.internal
+
+import android.view.InputDevice.SOURCE_DPAD
+import android.view.KeyEvent.KEYCODE_DPAD_CENTER
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN
+import android.view.KeyEvent.KEYCODE_DPAD_LEFT
+import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
+import android.view.KeyEvent.KEYCODE_DPAD_UP
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.selection.TextFieldSelectionState
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.nativeKeyCode
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.SoftwareKeyboardController
+
+internal actual fun createTextFieldKeyEventHandler(): TextFieldKeyEventHandler =
+    AndroidTextFieldKeyEventHandler()
+
+@OptIn(ExperimentalFoundationApi::class)
+internal class AndroidTextFieldKeyEventHandler : TextFieldKeyEventHandler() {
+
+    override fun onPreKeyEvent(
+        event: KeyEvent,
+        textFieldState: TextFieldState,
+        textFieldSelectionState: TextFieldSelectionState,
+        focusManager: FocusManager,
+        keyboardController: SoftwareKeyboardController
+    ): Boolean {
+        // do not proceed if common code has consumed the event
+        if (
+            super.onPreKeyEvent(
+                event = event,
+                textFieldState = textFieldState,
+                textFieldSelectionState = textFieldSelectionState,
+                focusManager = focusManager,
+                keyboardController = keyboardController
+            )
+        ) return true
+
+        val device = event.nativeKeyEvent.device
+        return when {
+            device == null -> false
+
+            // Ignore key events from non-dpad sources
+            !device.supportsSource(SOURCE_DPAD) -> false
+
+            // Ignore key events from virtual keyboards
+            device.isVirtual -> false
+
+            // Ignore key release events
+            event.type != KeyDown -> false
+
+            event.isKeyCode(KEYCODE_DPAD_UP) -> focusManager.moveFocus(FocusDirection.Up)
+            event.isKeyCode(KEYCODE_DPAD_DOWN) -> focusManager.moveFocus(FocusDirection.Down)
+            event.isKeyCode(KEYCODE_DPAD_LEFT) -> focusManager.moveFocus(FocusDirection.Left)
+            event.isKeyCode(KEYCODE_DPAD_RIGHT) -> focusManager.moveFocus(FocusDirection.Right)
+            event.isKeyCode(KEYCODE_DPAD_CENTER) -> {
+                // Enable keyboard on center key press
+                keyboardController.show()
+                true
+            }
+            else -> false
+        }
+    }
+}
+
+private fun KeyEvent.isKeyCode(keyCode: Int): Boolean =
+    this.key.nativeKeyCode == keyCode
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandles.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandles.android.kt
index c6183a9..80d03f5 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandles.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandles.android.kt
@@ -93,7 +93,7 @@
     handleReferencePoint: HandleReferencePoint,
     content: @Composable () -> Unit
 ) {
-    val popupPositioner = remember(handleReferencePoint) {
+    val popupPositioner = remember(handleReferencePoint, positionProvider) {
         HandlePositionProvider2(handleReferencePoint, positionProvider)
     }
 
@@ -112,15 +112,24 @@
     private val positionProvider: OffsetProvider
 ) : PopupPositionProvider {
 
+    /**
+     * When Handle disappears, it starts reporting its position as [Offset.Unspecified]. Normally,
+     * Popup is dismissed immediately when its position becomes unspecified, but for one frame a
+     * position update might be requested by soon-to-be-destroyed Popup. In this case, report the
+     * last known position as there are no more updates. If the first ever position is provided as
+     * unspecified, start with [Offset.Zero] default.
+     */
+    private var prevPosition: Offset = Offset.Zero
+
     override fun calculatePosition(
         anchorBounds: IntRect,
         windowSize: IntSize,
         layoutDirection: LayoutDirection,
         popupContentSize: IntSize
     ): IntOffset {
-        val intOffset = positionProvider.provide().takeOrElse {
-            Offset(Float.MAX_VALUE, Float.MAX_VALUE)
-        }.round()
+        val position = positionProvider.provide().takeOrElse { prevPosition }
+        prevPosition = position
+        val intOffset = position.round()
 
         return when (handleReferencePoint) {
             HandleReferencePoint.TopLeft ->
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
index 4a66847..4b77a1d 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/BasicMarquee.kt
@@ -131,6 +131,9 @@
  * @param spacing A [MarqueeSpacing] that specifies how much space to leave at the end of the
  * content before showing the beginning again.
  * @param velocity The speed of the animation in dps / second.
+ *
+ * Note: this modifier and corresponding APIs are experimental pending some refinements in the API
+ * surface, mostly related to customisation params.
  */
 @ExperimentalFoundationApi
 fun Modifier.basicMarquee(
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Canvas.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Canvas.kt
index 4c57fef..9f98397 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Canvas.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Canvas.kt
@@ -59,7 +59,6 @@
  * called during draw stage, you have no access to composition scope, meaning that [Composable]
  * function invocation inside it will result to runtime exception
  */
-@ExperimentalFoundationApi
 @Composable
 fun Canvas(modifier: Modifier, contentDescription: String, onDraw: DrawScope.() -> Unit) =
     Spacer(modifier.drawBehind(onDraw).semantics { this.contentDescription = contentDescription })
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
index f117895..3af83ab 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Clickable.kt
@@ -177,6 +177,9 @@
  * @param onLongClick will be called when user long presses on the element
  * @param onDoubleClick will be called when user double clicks on the element
  * @param onClick will be called when user clicks on the element
+ *
+ * Note: This API is experimental and is awaiting a rework. combinedClickable handles touch based
+ * input quite well but provides subpar functionality for other input types.
  */
 @ExperimentalFoundationApi
 fun Modifier.combinedClickable(
@@ -239,6 +242,9 @@
  * @param onLongClick will be called when user long presses on the element
  * @param onDoubleClick will be called when user double clicks on the element
  * @param onClick will be called when user clicks on the element
+ *
+ * Note: This API is experimental and is awaiting a rework. combinedClickable handles touch based
+ * input quite well but provides subpar functionality for other input types.
  */
 @ExperimentalFoundationApi
 fun Modifier.combinedClickable(
@@ -594,6 +600,9 @@
  * @param onClickLabel semantic / accessibility label for the [onClick] action
  * @param role the type of user interface element. Accessibility services might use this
  * to describe the element or do customizations
+ *
+ * Note: This API is experimental and is awaiting a rework. combinedClickable handles touch based
+ * input quite well but provides subpar functionality for other input types.
  */
 @ExperimentalFoundationApi
 fun CombinedClickableNode(
@@ -620,8 +629,8 @@
  * Public interface for the internal node used inside [combinedClickable], to allow for custom
  * modifier nodes to delegate to it.
  *
- * This API is experimental and is temporarily being exposed to enable performance analysis, you
- * should use [combinedClickable] instead for the majority of use cases.
+ * Note: This API is experimental and is temporarily being exposed to enable performance analysis,
+ * you should use [combinedClickable] instead for the majority of use cases.
  */
 @ExperimentalFoundationApi
 sealed interface CombinedClickableNode : PointerInputModifierNode {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
index 412590f..e14ccc1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
@@ -111,9 +111,11 @@
         }
 
     /**
-     * Size of the viewport on the scrollable axis, or 0 if still unknown.
+     * Size of the viewport on the scrollable axis, or 0 if still unknown. Note that this value
+     * is only populated after the first measure pass.
      */
-    internal var viewportSize: Int by mutableIntStateOf(0)
+    var viewportSize: Int by mutableIntStateOf(0)
+        internal set
 
     /**
      * [InteractionSource] that will be used to dispatch drag events when this
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
index 327eed6..d7696e1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/AnchoredDraggable.kt
@@ -529,7 +529,10 @@
             }
         } finally {
             val closest = anchors.closestAnchor(offset)
-            if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
+            if (closest != null &&
+                abs(offset - anchors.positionOf(closest)) <= 0.5f &&
+                confirmValueChange.invoke(closest)
+            ) {
                 currentValue = closest
             }
         }
@@ -576,7 +579,10 @@
             } finally {
                 dragTarget = null
                 val closest = anchors.closestAnchor(offset)
-                if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
+                if (closest != null &&
+                    abs(offset - anchors.positionOf(closest)) <= 0.5f &&
+                    confirmValueChange.invoke(closest)
+                ) {
                     currentValue = closest
                 }
             }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
index 441dd46..a9b76b7 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewNode.kt
@@ -64,7 +64,7 @@
     private var orientation: Orientation,
     private var scrollState: ScrollableState,
     private var reverseDirection: Boolean,
-    private var bringIntoViewScroller: BringIntoViewScroller
+    private var bringIntoViewSpec: BringIntoViewSpec
 ) : Modifier.Node(), BringIntoViewResponder, LayoutAwareModifierNode {
 
     /**
@@ -102,7 +102,7 @@
     private var viewportSize = IntSize.Zero
     private var isAnimationRunning = false
     private val animationState =
-        UpdatableAnimationState(bringIntoViewScroller.scrollAnimationSpec)
+        UpdatableAnimationState(bringIntoViewSpec.scrollAnimationSpec)
 
     override fun calculateRectForParent(localRect: Rect): Rect {
         check(viewportSize != IntSize.Zero) {
@@ -299,13 +299,13 @@
 
         val size = viewportSize.toSize()
         return when (orientation) {
-            Vertical -> bringIntoViewScroller.calculateScrollDistance(
+            Vertical -> bringIntoViewSpec.calculateScrollDistance(
                 rectangleToMakeVisible.top,
                 rectangleToMakeVisible.bottom - rectangleToMakeVisible.top,
                 size.height
             )
 
-            Horizontal -> bringIntoViewScroller.calculateScrollDistance(
+            Horizontal -> bringIntoViewSpec.calculateScrollDistance(
                 rectangleToMakeVisible.left,
                 rectangleToMakeVisible.right - rectangleToMakeVisible.left,
                 size.width
@@ -362,7 +362,7 @@
         return when (orientation) {
             Vertical -> Offset(
                 x = 0f,
-                y = bringIntoViewScroller.calculateScrollDistance(
+                y = bringIntoViewSpec.calculateScrollDistance(
                     childBounds.top,
                     childBounds.bottom - childBounds.top,
                     size.height
@@ -370,7 +370,7 @@
             )
 
             Horizontal -> Offset(
-                x = bringIntoViewScroller.calculateScrollDistance(
+                x = bringIntoViewSpec.calculateScrollDistance(
                     childBounds.left,
                     childBounds.right - childBounds.left,
                     size.width
@@ -394,12 +394,12 @@
         orientation: Orientation,
         state: ScrollableState,
         reverseDirection: Boolean,
-        bringIntoViewScroller: BringIntoViewScroller
+        bringIntoViewSpec: BringIntoViewSpec
     ) {
         this.orientation = orientation
         this.scrollState = state
         this.reverseDirection = reverseDirection
-        this.bringIntoViewScroller = bringIntoViewScroller
+        this.bringIntoViewSpec = bringIntoViewSpec
     }
 
     /**
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
index 5343ef8..7a75b6b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt
@@ -27,7 +27,7 @@
 import androidx.compose.foundation.FocusedBoundsObserverNode
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.OverscrollEffect
-import androidx.compose.foundation.gestures.BringIntoViewScroller.Companion.DefaultBringIntoViewScroller
+import androidx.compose.foundation.gestures.BringIntoViewSpec.Companion.DefaultBringIntoViewSpec
 import androidx.compose.foundation.gestures.Orientation.Horizontal
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.relocation.BringIntoViewResponderNode
@@ -150,8 +150,11 @@
  * `null`, default from [ScrollableDefaults.flingBehavior] will be used.
  * @param interactionSource [MutableInteractionSource] that will be used to emit
  * drag events when this scrollable is being dragged.
- * @param bringIntoViewScroller The configuration that this scrollable should use to perform
+ * @param bringIntoViewSpec The configuration that this scrollable should use to perform
  * scrolling when scroll requests are received from the focus system.
+ *
+ * Note: This API is experimental as it brings support for some experimental features:
+ * [overscrollEffect] and [bringIntoViewScroller].
  */
 @ExperimentalFoundationApi
 fun Modifier.scrollable(
@@ -162,7 +165,7 @@
     reverseDirection: Boolean = false,
     flingBehavior: FlingBehavior? = null,
     interactionSource: MutableInteractionSource? = null,
-    bringIntoViewScroller: BringIntoViewScroller = ScrollableDefaults.bringIntoViewScroller()
+    bringIntoViewSpec: BringIntoViewSpec = ScrollableDefaults.bringIntoViewSpec()
 ) = this then ScrollableElement(
     state,
     orientation,
@@ -171,7 +174,7 @@
     reverseDirection,
     flingBehavior,
     interactionSource,
-    bringIntoViewScroller
+    bringIntoViewSpec
 )
 
 @OptIn(ExperimentalFoundationApi::class)
@@ -183,7 +186,7 @@
     val reverseDirection: Boolean,
     val flingBehavior: FlingBehavior?,
     val interactionSource: MutableInteractionSource?,
-    val bringIntoViewScroller: BringIntoViewScroller
+    val bringIntoViewSpec: BringIntoViewSpec
 ) : ModifierNodeElement<ScrollableNode>() {
     override fun create(): ScrollableNode {
         return ScrollableNode(
@@ -194,7 +197,7 @@
             reverseDirection,
             flingBehavior,
             interactionSource,
-            bringIntoViewScroller
+            bringIntoViewSpec
         )
     }
 
@@ -207,7 +210,7 @@
             reverseDirection,
             flingBehavior,
             interactionSource,
-            bringIntoViewScroller
+            bringIntoViewSpec
         )
     }
 
@@ -219,7 +222,7 @@
         result = 31 * result + reverseDirection.hashCode()
         result = 31 * result + flingBehavior.hashCode()
         result = 31 * result + interactionSource.hashCode()
-        result = 31 * result + bringIntoViewScroller.hashCode()
+        result = 31 * result + bringIntoViewSpec.hashCode()
         return result
     }
 
@@ -235,7 +238,7 @@
         if (reverseDirection != other.reverseDirection) return false
         if (flingBehavior != other.flingBehavior) return false
         if (interactionSource != other.interactionSource) return false
-        if (bringIntoViewScroller != other.bringIntoViewScroller) return false
+        if (bringIntoViewSpec != other.bringIntoViewSpec) return false
 
         return true
     }
@@ -249,7 +252,7 @@
         properties["reverseDirection"] = reverseDirection
         properties["flingBehavior"] = flingBehavior
         properties["interactionSource"] = interactionSource
-        properties["scrollableBringIntoViewConfig"] = bringIntoViewScroller
+        properties["scrollableBringIntoViewConfig"] = bringIntoViewSpec
     }
 }
 
@@ -262,7 +265,7 @@
     private var reverseDirection: Boolean,
     private var flingBehavior: FlingBehavior?,
     private var interactionSource: MutableInteractionSource?,
-    bringIntoViewScroller: BringIntoViewScroller
+    bringIntoViewSpec: BringIntoViewSpec
 ) : DelegatingNode(), ObserverModifierNode, CompositionLocalConsumerModifierNode,
     FocusPropertiesModifierNode {
     val nestedScrollDispatcher = NestedScrollDispatcher()
@@ -288,7 +291,7 @@
                 orientation,
                 state,
                 reverseDirection,
-                bringIntoViewScroller
+                bringIntoViewSpec
             )
         )
     val scrollableContainer = delegate(ModifierLocalScrollableContainerProvider(enabled))
@@ -328,7 +331,7 @@
         reverseDirection: Boolean,
         flingBehavior: FlingBehavior?,
         interactionSource: MutableInteractionSource?,
-        bringIntoViewScroller: BringIntoViewScroller
+        bringIntoViewSpec: BringIntoViewSpec
     ) {
 
         if (this.enabled != enabled) { // enabled changed
@@ -357,7 +360,7 @@
             orientation,
             state,
             reverseDirection,
-            bringIntoViewScroller
+            bringIntoViewSpec
         )
 
         this.state = state
@@ -392,21 +395,23 @@
 
 /**
  * The configuration of how a scrollable reacts to bring into view requests.
+ *
+ * Note: API shape and naming are still being refined, therefore API is marked as experimental.
  */
 @ExperimentalFoundationApi
 @Stable
-interface BringIntoViewScroller {
+interface BringIntoViewSpec {
 
     /**
      * A retargetable Animation Spec to be used as the animation to run to fulfill the
      * BringIntoView requests.
      */
-    val scrollAnimationSpec: AnimationSpec<Float>
+    val scrollAnimationSpec: AnimationSpec<Float> get() = DefaultScrollAnimationSpec
 
     /**
      * Calculate the offset needed to bring one of the scrollable container's child into view.
      *
-     * @param offset is the side closest to the origin (For the x-axis this is 'left',
+     * @param offset from the side closest to the origin (For the x-axis this is 'left',
      * for the y-axis this is 'top').
      * @param size is the child size.
      * @param containerSize Is the main axis size of the scrollable container.
@@ -433,7 +438,7 @@
          */
         val DefaultScrollAnimationSpec: AnimationSpec<Float> = spring()
 
-        internal val DefaultBringIntoViewScroller = object : BringIntoViewScroller {
+        internal val DefaultBringIntoViewSpec = object : BringIntoViewSpec {
 
             override val scrollAnimationSpec: AnimationSpec<Float> = DefaultScrollAnimationSpec
 
@@ -515,11 +520,11 @@
     }
 
     /**
-     * A default implementation for [BringIntoViewScroller] that brings a child into view
+     * A default implementation for [BringIntoViewSpec] that brings a child into view
      * using the least amount of effort.
      */
     @ExperimentalFoundationApi
-    fun bringIntoViewScroller(): BringIntoViewScroller = DefaultBringIntoViewScroller
+    fun bringIntoViewSpec(): BringIntoViewSpec = DefaultBringIntoViewSpec
 }
 
 internal interface ScrollConfig {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt
index 98ea94e..a803eac 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyDsl.kt
@@ -109,6 +109,9 @@
      * type could be reused more efficiently. Note that null is a valid type and items of such
      * type will be considered compatible.
      * @param content the content of the header
+     *
+     * Note: More investigations needed to make sure sticky headers API is suitable for various
+     * more generic usecases, e.g. in grids. This API is experimental until the answer is found.
      */
     @ExperimentalFoundationApi
     fun stickyHeader(
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt
index f5f8349..c5cc322 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt
@@ -16,13 +16,10 @@
 
 package androidx.compose.foundation.lazy.grid
 
-import androidx.compose.foundation.ExperimentalFoundationApi
-
 /**
  * Represents one measured line of the lazy list. Each item on the line can in fact consist of
  * multiple placeables if the user emit multiple layout nodes in the item callback.
  */
-@OptIn(ExperimentalFoundationApi::class)
 internal class LazyGridMeasuredLine constructor(
     val index: Int,
     val items: Array<LazyGridMeasuredItem>,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt
index 083c717..6bbd299 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLineProvider.kt
@@ -16,14 +16,12 @@
 
 package androidx.compose.foundation.lazy.grid
 
-import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.lazy.layout.LazyLayoutKeyIndexMap
 import androidx.compose.ui.unit.Constraints
 
 /**
  * Abstracts away subcomposition and span calculation from the measuring logic of entire lines.
  */
-@OptIn(ExperimentalFoundationApi::class)
 internal abstract class LazyGridMeasuredLineProvider(
     private val isVertical: Boolean,
     private val slots: LazyGridSlots,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpan.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpan.kt
index 3b2538e..d42e065 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpan.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpan.kt
@@ -17,7 +17,6 @@
 package androidx.compose.foundation.lazy.grid
 
 import androidx.annotation.IntRange
-import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.runtime.Immutable
 
 /**
@@ -30,7 +29,6 @@
      * The span of the item on the current line. This will be the horizontal span for items of
      * [LazyVerticalGrid].
      */
-    @ExperimentalFoundationApi
     val currentLineSpan: Int get() = packedValue.toInt()
 }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/IntervalList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/IntervalList.kt
index c842697..240952a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/IntervalList.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/IntervalList.kt
@@ -29,6 +29,9 @@
  * This interface is read only, in order to create a list you need to use [MutableIntervalList].
  *
  * @param T type of values each interval contains in [Interval.value].
+ *
+ * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy layouts.
+ * LazyLayout and all corresponding APIs are still under development and are subject to change.
  */
 @ExperimentalFoundationApi
 sealed interface IntervalList<out T> {
@@ -92,6 +95,9 @@
 
 /**
  * Mutable version of [IntervalList]. It allows you to add new intervals via [addInterval].
+ *
+ * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy layouts.
+ * LazyLayout and all corresponding APIs are still under development and are subject to change.
  */
 @ExperimentalFoundationApi
 class MutableIntervalList<T> : IntervalList<T> {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt
index 6c08e89..031d0f1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayout.kt
@@ -63,6 +63,10 @@
  * @param modifier to apply on the layout
  * @param prefetchState allows to schedule items for prefetching
  * @param measurePolicy Measure policy which allows to only compose and measure needed items.
+ *
+ * Note: this function is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @ExperimentalFoundationApi
 @Composable
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutIntervalContent.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutIntervalContent.kt
index 90665f1..b124361 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutIntervalContent.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutIntervalContent.kt
@@ -20,6 +20,9 @@
 
 /**
  * Common parts backing the interval-based content of lazy layout defined through `item` DSL.
+ *
+ * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy layouts.
+ * LazyLayout and all corresponding APIs are still under development and are subject to change.
  */
 @ExperimentalFoundationApi
 abstract class LazyLayoutIntervalContent<Interval : LazyLayoutIntervalContent.Interval> {
@@ -61,6 +64,10 @@
 
     /**
      * Common content of individual intervals in `item` DSL of lazy layouts.
+     *
+     * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy
+     * layouts. LazyLayout and all corresponding APIs are still under development and are subject
+     * to change.
      */
     @ExperimentalFoundationApi
     interface Interval {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt
index dbfa4e9..cf60f3b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemProvider.kt
@@ -23,6 +23,10 @@
 /**
  * Provides all the needed info about the items which could be later composed and displayed as
  * children or [LazyLayout].
+ *
+ * Note: this interface is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @Stable
 @ExperimentalFoundationApi
@@ -93,6 +97,10 @@
  * 1) Objects created for the same index are equals and never equals for different indexes.
  * 2) This class is saveable via a default SaveableStateRegistry on the platform.
  * 3) This objects can't be equals to any object which could be provided by a user as a custom key.
+ *
+ * Note: this function is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @ExperimentalFoundationApi
 @Suppress("MissingNullability")
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt
index c8b3c0d..286abc4 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutMeasureScope.kt
@@ -40,6 +40,10 @@
  * Main difference from the regular flow of writing any custom layout is that you have a new
  * function [measure] which accepts item index and constraints, composes the item based and then
  * measures all the layouts emitted in the item content block.
+ *
+ * Note: this interface is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @Stable
 @ExperimentalFoundationApi
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPinnableItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPinnableItem.kt
index 680f8b5..3cbb799 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPinnableItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPinnableItem.kt
@@ -38,6 +38,10 @@
  * @param index index of the item inside the lazy layout
  * @param pinnedItemList container of currently pinned items
  * @param content inner content of this item
+ *
+ * Note: this function is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @ExperimentalFoundationApi
 @Composable
@@ -60,6 +64,10 @@
  * Read-only list of pinned items in a lazy layout.
  * The items are modified internally by the [PinnableContainer] consumers, for example if something
  * inside item content is focused.
+ *
+ * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @ExperimentalFoundationApi
 class LazyLayoutPinnedItemList private constructor(
@@ -78,6 +86,10 @@
     /**
      * Item pinned in a lazy layout. Pinned item should be always measured and laid out,
      * even if the item is beyond the boundaries of the layout.
+     *
+     * Note: this interface is a part of [LazyLayout] harness that allows for building custom lazy
+     * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+     * change.
      */
     @ExperimentalFoundationApi
     sealed interface PinnedItem {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt
index 6937c59..3aa8757 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutPrefetchState.kt
@@ -22,6 +22,10 @@
 
 /**
  * State for lazy items prefetching, used by lazy layouts to instruct the prefetcher.
+ *
+ * Note: this class is a part of [LazyLayout] harness that allows for building custom lazy
+ * layouts. LazyLayout and all corresponding APIs are still under development and are subject to
+ * change.
  */
 @ExperimentalFoundationApi
 @Stable
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
index 2bae034..6582c25 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
@@ -20,7 +20,7 @@
 import androidx.compose.animation.core.spring
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.clipScrollableContainer
-import androidx.compose.foundation.gestures.BringIntoViewScroller
+import androidx.compose.foundation.gestures.BringIntoViewSpec
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.ScrollableDefaults
 import androidx.compose.foundation.gestures.awaitEachGesture
@@ -75,8 +75,8 @@
     flingBehavior: SnapFlingBehavior,
     /** Whether scrolling via the user gestures is allowed. */
     userScrollEnabled: Boolean,
-    /** Number of pages to layout before and after the visible pages */
-    beyondBoundsPageCount: Int = 0,
+    /** Number of pages to compose and layout before and after the visible pages */
+    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
     /** Space between pages */
     pageSpacing: Dp = 0.dp,
     /** Allows to change how to calculate the Page size */
@@ -129,7 +129,7 @@
         orientation == Orientation.Vertical
     )
 
-    val pagerBringIntoViewScroller = remember(state) { PagerBringIntoViewScroller(state) }
+    val pagerBringIntoViewSpec = remember(state) { PagerBringIntoViewSpec(state) }
 
     LazyLayout(
         modifier = modifier
@@ -162,7 +162,7 @@
                 state = state,
                 overscrollEffect = overscrollEffect,
                 enabled = userScrollEnabled,
-                bringIntoViewScroller = pagerBringIntoViewScroller
+                bringIntoViewSpec = pagerBringIntoViewSpec
             )
             .dragDirectionDetector(state)
             .nestedScroll(pageNestedScrollConnection),
@@ -282,7 +282,7 @@
     }
 
 @OptIn(ExperimentalFoundationApi::class)
-private class PagerBringIntoViewScroller(val pagerState: PagerState) : BringIntoViewScroller {
+private class PagerBringIntoViewSpec(val pagerState: PagerState) : BringIntoViewSpec {
 
     override val scrollAnimationSpec: AnimationSpec<Float> = spring()
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
index 78c554f..6f7cad0 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
@@ -43,8 +43,8 @@
 
     override fun collectionInfo(): CollectionInfo =
         if (isVertical) {
-            CollectionInfo(rowCount = -1, columnCount = 1)
+            CollectionInfo(rowCount = state.pageCount, columnCount = 1)
         } else {
-            CollectionInfo(rowCount = 1, columnCount = -1)
+            CollectionInfo(rowCount = 1, columnCount = state.pageCount)
         }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
index 4956155..a9a34f3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
@@ -81,11 +81,12 @@
  * to add a padding before the first page or after the last one. Use [pageSpacing] to add spacing
  * between the pages.
  * @param pageSize Use this to change how the pages will look like inside this pager.
- * @param beyondBoundsPageCount Pages to load before and after the list of visible
+ * @param beyondBoundsPageCount Pages to compose and layout before and after the list of visible
  * pages. Note: Be aware that using a large value for [beyondBoundsPageCount] will cause a lot of
  * pages to be composed, measured and placed which will defeat the purpose of using lazy loading.
  * This should be used as an optimization to pre-load a couple of pages before and after the visible
- * ones.
+ * ones. This does not include the pages automatically composed and laid out by the pre-fetcher in
+ * the direction of the scroll during scroll events.
  * @param pageSpacing The amount of space to be used to separate the pages in this Pager
  * @param verticalAlignment How pages are aligned vertically in this Pager.
  * @param flingBehavior The [FlingBehavior] to be used for post scroll gestures.
@@ -107,7 +108,7 @@
     modifier: Modifier = Modifier,
     contentPadding: PaddingValues = PaddingValues(0.dp),
     pageSize: PageSize = PageSize.Fill,
-    beyondBoundsPageCount: Int = 0,
+    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
     pageSpacing: Dp = 0.dp,
     verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
     flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
@@ -160,11 +161,12 @@
  * to add a padding before the first page or after the last one. Use [pageSpacing] to add spacing
  * between the pages.
  * @param pageSize Use this to change how the pages will look like inside this pager.
- * @param beyondBoundsPageCount Pages to load before and after the list of visible
+ * @param beyondBoundsPageCount Pages to compose and layout before and after the list of visible
  * pages. Note: Be aware that using a large value for [beyondBoundsPageCount] will cause a lot of
  * pages to be composed, measured and placed which will defeat the purpose of using lazy loading.
  * This should be used as an optimization to pre-load a couple of pages before and after the visible
- * ones.
+ * ones. This does not include the pages automatically composed and laid out by the pre-fetcher in
+ *  * the direction of the scroll during scroll events.
  * @param pageSpacing The amount of space to be used to separate the pages in this Pager
  * @param verticalAlignment How pages are aligned vertically in this Pager.
  * @param flingBehavior The [FlingBehavior] to be used for post scroll gestures.
@@ -215,7 +217,7 @@
     state: PagerState = rememberPagerState { pageCount },
     contentPadding: PaddingValues = PaddingValues(0.dp),
     pageSize: PageSize = PageSize.Fill,
-    beyondBoundsPageCount: Int = 0,
+    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
     pageSpacing: Dp = 0.dp,
     verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
     flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
@@ -267,11 +269,12 @@
  * to add a padding before the first page or after the last one. Use [pageSpacing] to add spacing
  * between the pages.
  * @param pageSize Use this to change how the pages will look like inside this pager.
- * @param beyondBoundsPageCount Pages to load before and after the list of visible
+ * @param beyondBoundsPageCount Pages to compose and layout before and after the list of visible
  * pages. Note: Be aware that using a large value for [beyondBoundsPageCount] will cause a lot of
  * pages to be composed, measured and placed which will defeat the purpose of using lazy loading.
  * This should be used as an optimization to pre-load a couple of pages before and after the visible
- * ones.
+ * ones. This does not include the pages automatically composed and laid out by the pre-fetcher in
+ *  * the direction of the scroll during scroll events.
  * @param pageSpacing The amount of space to be used to separate the pages in this Pager
  * @param horizontalAlignment How pages are aligned horizontally in this Pager.
  * @param flingBehavior The [FlingBehavior] to be used for post scroll gestures.
@@ -293,7 +296,7 @@
     modifier: Modifier = Modifier,
     contentPadding: PaddingValues = PaddingValues(0.dp),
     pageSize: PageSize = PageSize.Fill,
-    beyondBoundsPageCount: Int = 0,
+    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
     pageSpacing: Dp = 0.dp,
     horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
     flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
@@ -346,11 +349,12 @@
  * to add a padding before the first page or after the last one. Use [pageSpacing] to add spacing
  * between the pages.
  * @param pageSize Use this to change how the pages will look like inside this pager.
- * @param beyondBoundsPageCount Pages to load before and after the list of visible
+ * @param beyondBoundsPageCount Pages to compose and layout before and after the list of visible
  * pages. Note: Be aware that using a large value for [beyondBoundsPageCount] will cause a lot of
  * pages to be composed, measured and placed which will defeat the purpose of using lazy loading.
  * This should be used as an optimization to pre-load a couple of pages before and after the visible
- * ones.
+ * ones. This does not include the pages automatically composed and laid out by the pre-fetcher in
+ *  * the direction of the scroll during scroll events.
  * @param pageSpacing The amount of space to be used to separate the pages in this Pager
  * @param horizontalAlignment How pages are aligned horizontally in this Pager.
  * @param flingBehavior The [FlingBehavior] to be used for post scroll gestures.
@@ -400,7 +404,7 @@
     state: PagerState = rememberPagerState { pageCount },
     contentPadding: PaddingValues = PaddingValues(0.dp),
     pageSize: PageSize = PageSize.Fill,
-    beyondBoundsPageCount: Int = 0,
+    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
     pageSpacing: Dp = 0.dp,
     horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
     flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
@@ -603,6 +607,13 @@
     ): NestedScrollConnection {
         return DefaultPagerNestedScrollConnection(state, orientation)
     }
+
+    /**
+     * The default value of beyondBoundsPageCount used to specify the number of pages to compose
+     * and layout before and after the visible pages. It does not include the pages automatically
+     * composed and laid out by the pre-fetcher in the direction of the scroll during scroll events.
+     */
+    const val BeyondBoundsPageCount = 0
 }
 
 /**
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerLayoutInfo.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerLayoutInfo.kt
index 74f1f87..b14f6e2 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerLayoutInfo.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerLayoutInfo.kt
@@ -91,6 +91,13 @@
     @Suppress("GetterSetterNames")
     @get:Suppress("GetterSetterNames")
     val reverseLayout: Boolean
+
+    /**
+     * Pages to compose and layout before and after the list of visible pages. This does not include
+     * the pages automatically composed and laid out by the pre-fetcher in the direction of the
+     * scroll during scroll events.
+     */
+    val beyondBoundsPageCount: Int
 }
 
 @ExperimentalFoundationApi
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
index 9aac2c0..d54ff5f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
@@ -75,6 +75,7 @@
             firstVisiblePage = null,
             firstVisiblePageOffset = 0,
             reverseLayout = false,
+            beyondBoundsPageCount = beyondBoundsPageCount,
             canScrollForward = false
         )
     } else {
@@ -388,6 +389,7 @@
             pageSize = pageAvailableSize,
             pageSpacing = spaceBetweenPages,
             afterContentPadding = afterContentPadding,
+            beyondBoundsPageCount = beyondBoundsPageCount,
             canScrollForward = index < pageCount || currentMainAxisOffset > maxOffset
         )
     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
index 381c691..f0767a6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasureResult.kt
@@ -31,6 +31,7 @@
     override val viewportStartOffset: Int,
     override val viewportEndOffset: Int,
     override val reverseLayout: Boolean,
+    override val beyondBoundsPageCount: Int,
     val consumedScroll: Float,
     val firstVisiblePage: MeasuredPage?,
     val firstVisiblePageOffset: Int,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
index e81c0dd..1e2219a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
@@ -605,9 +605,9 @@
         if (info.visiblePagesInfo.isNotEmpty()) {
             val scrollingForward = delta < 0
             val indexToPrefetch = if (scrollingForward) {
-                info.visiblePagesInfo.last().index + 1
+                info.visiblePagesInfo.last().index + info.beyondBoundsPageCount + PagesToPrefetch
             } else {
-                info.visiblePagesInfo.first().index - 1
+                info.visiblePagesInfo.first().index - info.beyondBoundsPageCount - PagesToPrefetch
             }
             if (indexToPrefetch != this.indexToPrefetch &&
                 indexToPrefetch in 0 until pageCount
@@ -631,9 +631,9 @@
     private fun cancelPrefetchIfVisibleItemsChanged(info: PagerLayoutInfo) {
         if (indexToPrefetch != -1 && info.visiblePagesInfo.isNotEmpty()) {
             val expectedPrefetchIndex = if (wasScrollingForward) {
-                info.visiblePagesInfo.last().index + 1
+                info.visiblePagesInfo.last().index + info.beyondBoundsPageCount + PagesToPrefetch
             } else {
-                info.visiblePagesInfo.first().index - 1
+                info.visiblePagesInfo.first().index - info.beyondBoundsPageCount - PagesToPrefetch
             }
             if (indexToPrefetch != expectedPrefetchIndex) {
                 indexToPrefetch = -1
@@ -676,6 +676,7 @@
 private const val MaxPageOffset = 0.5f
 internal val DefaultPositionThreshold = 56.dp
 private const val MaxPagesForAnimateScroll = 3
+internal const val PagesToPrefetch = 1
 
 @OptIn(ExperimentalFoundationApi::class)
 internal object EmptyLayoutInfo : PagerLayoutInfo {
@@ -689,6 +690,7 @@
     override val viewportStartOffset: Int = 0
     override val viewportEndOffset: Int = 0
     override val reverseLayout: Boolean = false
+    override val beyondBoundsPageCount: Int = 0
 }
 
 private val UnitDensity = object : Density {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/ClickableText.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/ClickableText.kt
index 6508a37..cb59375 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/ClickableText.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/ClickableText.kt
@@ -135,6 +135,9 @@
  * hovering this.
  * @param onClick Callback that is executed when users click the text. This callback is called
  * with clicked character's offset.
+ *
+ * Note: API research for improvements on clickable text and related functionality is still ongoing
+ * so keeping this experimental to avoid future churn.
  */
 @ExperimentalFoundationApi // when removing this experimental annotation,
 // onHover should be nullable with null as default. The other ClickableText
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicSecureTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicSecureTextField.kt
index a22cb59..ed0aaa88 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicSecureTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicSecureTextField.kt
@@ -20,9 +20,12 @@
 import androidx.compose.foundation.ScrollState
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyCommand
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.platformDefaultKeyMapping
 import androidx.compose.foundation.text2.input.CodepointTransformation
 import androidx.compose.foundation.text2.input.TextEditFilter
 import androidx.compose.foundation.text2.input.TextFieldBuffer
@@ -45,6 +48,7 @@
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.onPreviewKeyEvent
 import androidx.compose.ui.platform.LocalTextToolbar
 import androidx.compose.ui.platform.TextToolbar
 import androidx.compose.ui.platform.TextToolbarStatus
@@ -135,7 +139,7 @@
     interactionSource: MutableInteractionSource? = null,
     cursorBrush: Brush = SolidColor(Color.Black),
     scrollState: ScrollState = rememberScrollState(),
-    onTextLayout: Density.(TextLayoutResult) -> Unit = {},
+    onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit = {},
     decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
         @Composable { innerTextField -> innerTextField() }
 ) {
@@ -182,7 +186,7 @@
             }
         )
 
-    DisableCopyTextToolbar {
+    DisableCutCopy {
         BasicTextField2(
             state = state,
             modifier = secureTextFieldModifier,
@@ -319,11 +323,11 @@
 )
 
 /**
- * Overrides the TextToolbar provided by LocalTextToolbar to never show copy or cut options by the
- * children composables.
+ * Overrides the TextToolbar and keyboard shortcuts to never allow copy or cut options by the
+ * composables inside [content].
  */
 @Composable
-private fun DisableCopyTextToolbar(
+private fun DisableCutCopy(
     content: @Composable () -> Unit
 ) {
     val currentToolbar = LocalTextToolbar.current
@@ -353,5 +357,14 @@
                 get() = currentToolbar.status
         }
     }
-    CompositionLocalProvider(LocalTextToolbar provides copyDisabledToolbar, content = content)
+    CompositionLocalProvider(LocalTextToolbar provides copyDisabledToolbar) {
+        Box(modifier = Modifier.onPreviewKeyEvent { keyEvent ->
+            // BasicTextField2 uses this static mapping
+            val command = platformDefaultKeyMapping.map(keyEvent)
+            // do not propagate copy and cut operations
+            command == KeyCommand.COPY || command == KeyCommand.CUT
+        }) {
+            content()
+        }
+    }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicTextField2.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicTextField2.kt
index a574637..3391eac6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicTextField2.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/BasicTextField2.kt
@@ -46,11 +46,15 @@
 import androidx.compose.foundation.text2.input.internal.TextFieldDecoratorModifier
 import androidx.compose.foundation.text2.input.internal.TextFieldTextLayoutModifier
 import androidx.compose.foundation.text2.input.internal.TextLayoutState
+import androidx.compose.foundation.text2.input.internal.syncTextFieldState
 import androidx.compose.foundation.text2.selection.TextFieldSelectionHandle2
 import androidx.compose.foundation.text2.selection.TextFieldSelectionState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.graphics.Brush
@@ -64,9 +68,11 @@
 import androidx.compose.ui.platform.LocalTextToolbar
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
 import androidx.compose.ui.unit.Density
 
 /**
@@ -77,6 +83,275 @@
  * Basic text composable that provides an interactive box that accepts text input through software
  * or hardware keyboard.
  *
+ * Whenever the user edits the text, [onValueChange] is called with the most up to date state
+ * represented by [String] with which developer is expected to update their state.
+ *
+ * While focused and being edited, the caller temporarily loses _direct_ control of the contents of
+ * the field through the [value] parameter. If an unexpected [value] is passed in during this time,
+ * the contents of the field will _not_ be updated to reflect the value until editing is done. When
+ * editing is done (i.e. focus is lost), the field will be updated to the last [value] received. Use
+ * a [filter] to accept or reject changes during editing. For more direct control of the field
+ * contents use the [BasicTextField2] overload that accepts a [TextFieldState].
+ *
+ * Unlike [TextFieldValue] overload, this composable does not let the developer control selection,
+ * cursor, and text composition information. Please check [TextFieldValue] and corresponding
+ * [BasicTextField2] overload for more information.
+ *
+ * @param value The input [String] text to be shown in the text field.
+ * @param onValueChange The callback that is triggered when the user or the system updates the
+ * text. The updated text is passed as a parameter of the callback. The value passed to the callback
+ * will already have had the [filter] applied.
+ * @param modifier optional [Modifier] for this text field.
+ * @param enabled controls the enabled state of the [BasicTextField2]. When `false`, the text
+ * field will be neither editable nor focusable, the input of the text field will not be selectable.
+ * @param readOnly controls the editable state of the [BasicTextField2]. When `true`, the text
+ * field can not be modified, however, a user can focus it and copy text from it. Read-only text
+ * fields are usually used to display pre-filled forms that user can not edit.
+ * @param filter Optional [TextEditFilter] that will be used to filter changes to the
+ * [TextFieldState] made by the user. The filter will be applied to changes made by hardware and
+ * software keyboard events, pasting or dropping text, accessibility services, and tests. The filter
+ * will _not_ be applied when a new [value] is passe din, or when the filter is changed.
+ * If the filter is changed on an existing text field, it will be applied to the next user edit, it
+ * will not immediately affect the current state.
+ * @param textStyle Typographic and graphic style configuration for text content that's displayed
+ * in the editor.
+ * @param keyboardOptions Software keyboard options that contain configurations such as
+ * [KeyboardType] and [ImeAction].
+ * @param keyboardActions When the input service emits an IME action, the corresponding callback
+ * is called. Note that this IME action may be different from what you specified in
+ * [KeyboardOptions.imeAction].
+ * @param lineLimits Whether the text field should be [SingleLine], scroll horizontally, and
+ * ignore newlines; or [MultiLine] and grow and scroll vertically. If [SingleLine] is passed without
+ * specifying the [codepointTransformation] parameter, a [CodepointTransformation] is automatically
+ * applied. This transformation replaces any newline characters ('\n') within the text with regular
+ * whitespace (' '), ensuring that the contents of the text field are presented in a single line.
+ * @param onTextLayout Callback that is executed when a new text layout is calculated. A
+ * [TextLayoutResult] object contains paragraph information, size of the text, baselines and other
+ * details. The callback can be used to add additional decoration or functionality to the text.
+ * For example, to draw a cursor or selection around the text. [Density] scope is the one that was
+ * used while creating the given text layout.
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this TextField. You can create and pass in your own remembered [MutableInteractionSource]
+ * if you want to observe [Interaction]s and customize the appearance / behavior of this TextField
+ * for different [Interaction]s.
+ * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified]
+ * provided, then no cursor will be drawn.
+ * @param scrollState Scroll state that manages either horizontal or vertical scroll of TextField.
+ * If [lineLimits] is [SingleLine], this text field is treated as single line with horizontal
+ * scroll behavior. In other cases the text field becomes vertically scrollable.
+ * @param codepointTransformation Visual transformation interface that provides a 1-to-1 mapping of
+ * codepoints.
+ * @param decorationBox Composable lambda that allows to add decorations around text field, such
+ * as icon, placeholder, helper messages or similar, and automatically increase the hit target area
+ * of the text field. To allow you to control the placement of the inner text field relative to your
+ * decorations, the text field implementation will pass in a framework-controlled composable
+ * parameter "innerTextField" to the decorationBox lambda you provide. You must call
+ * innerTextField exactly once.
+ */
+@ExperimentalFoundationApi
+@Composable
+fun BasicTextField2(
+    value: String,
+    onValueChange: (String) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    readOnly: Boolean = false,
+    filter: TextEditFilter? = null,
+    textStyle: TextStyle = TextStyle.Default,
+    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+    keyboardActions: KeyboardActions = KeyboardActions.Default,
+    lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
+    onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit = {},
+    interactionSource: MutableInteractionSource? = null,
+    cursorBrush: Brush = SolidColor(Color.Black),
+    scrollState: ScrollState = rememberScrollState(),
+    codepointTransformation: CodepointTransformation? = null,
+    decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
+        @Composable { innerTextField -> innerTextField() }
+) {
+    val state = remember {
+        TextFieldState(
+            initialText = value,
+            // Initialize the cursor to be at the end of the field.
+            initialSelectionInChars = TextRange(value.length)
+        )
+    }
+
+    // This is effectively a rememberUpdatedState, but it combines the updated state (text) with
+    // some state that is preserved across updates (selection).
+    var valueWithSelection by remember {
+        mutableStateOf(
+            TextFieldValue(
+                text = value,
+                selection = TextRange(value.length)
+            )
+        )
+    }
+    valueWithSelection = valueWithSelection.copy(text = value)
+
+    BasicTextField2(
+        state = state,
+        modifier = modifier.syncTextFieldState(
+            state = state,
+            value = valueWithSelection,
+            onValueChanged = {
+                // Don't fire the callback if only the selection/cursor changed.
+                if (it.text != valueWithSelection.text) {
+                    onValueChange(it.text)
+                }
+                valueWithSelection = it
+            },
+            writeSelectionFromTextFieldValue = false
+        ),
+        enabled = enabled,
+        readOnly = readOnly,
+        filter = filter,
+        textStyle = textStyle,
+        keyboardOptions = keyboardOptions,
+        keyboardActions = keyboardActions,
+        lineLimits = lineLimits,
+        onTextLayout = onTextLayout,
+        interactionSource = interactionSource,
+        cursorBrush = cursorBrush,
+        scrollState = scrollState,
+        codepointTransformation = codepointTransformation,
+        decorationBox = decorationBox,
+    )
+}
+
+/**
+ * BasicTextField2 is a new text input Composable under heavy development. Please refrain from
+ * using it in production since it has a very unstable API and implementation for the time being.
+ * Many core features like selection, cursor, gestures, etc. may fail or simply not exist.
+ *
+ * Basic text composable that provides an interactive box that accepts text input through software
+ * or hardware keyboard.
+ *
+ * Whenever the user edits the text, [onValueChange] is called with the most up to date state
+ * represented by [TextFieldValue] with which developer is expected to update their state.
+ *
+ * While focused and being edited, the caller temporarily loses _direct_ control of the contents of
+ * the field through the [value] parameter. If an unexpected [value] is passed in during this time,
+ * the contents of the field will _not_ be updated to reflect the value until editing is done. When
+ * editing is done (i.e. focus is lost), the field will be updated to the last [value] received. Use
+ * a [filter] to accept or reject changes during editing. For more direct control of the field
+ * contents use the [BasicTextField2] overload that accepts a [TextFieldState].
+ *
+ * This function ignores the [TextFieldValue.composition] property from [value]. The composition
+ * will, however, be reported in [onValueChange].
+ *
+ * @param value The input [TextFieldValue] specifying the text to be shown in the text field and
+ * the cursor position or selection.
+ * @param onValueChange The callback that is triggered when the user or the system updates the
+ * text, cursor, or selection. The updated [TextFieldValue] is passed as a parameter of the
+ * callback. The value passed to the callback will already have had the [filter] applied.
+ * @param modifier optional [Modifier] for this text field.
+ * @param enabled controls the enabled state of the [BasicTextField2]. When `false`, the text
+ * field will be neither editable nor focusable, the input of the text field will not be selectable.
+ * @param readOnly controls the editable state of the [BasicTextField2]. When `true`, the text
+ * field can not be modified, however, a user can focus it and copy text from it. Read-only text
+ * fields are usually used to display pre-filled forms that user can not edit.
+ * @param filter Optional [TextEditFilter] that will be used to filter changes to the
+ * [TextFieldState] made by the user. The filter will be applied to changes made by hardware and
+ * software keyboard events, pasting or dropping text, accessibility services, and tests. The filter
+ * will _not_ be applied when a new [value] is passed in, or when the filter is changed.
+ * If the filter is changed on an existing text field, it will be applied to the next user edit, it
+ * will not immediately affect the current state.
+ * @param textStyle Typographic and graphic style configuration for text content that's displayed
+ * in the editor.
+ * @param keyboardOptions Software keyboard options that contain configurations such as
+ * [KeyboardType] and [ImeAction].
+ * @param keyboardActions When the input service emits an IME action, the corresponding callback
+ * is called. Note that this IME action may be different from what you specified in
+ * [KeyboardOptions.imeAction].
+ * @param lineLimits Whether the text field should be [SingleLine], scroll horizontally, and
+ * ignore newlines; or [MultiLine] and grow and scroll vertically. If [SingleLine] is passed without
+ * specifying the [codepointTransformation] parameter, a [CodepointTransformation] is automatically
+ * applied. This transformation replaces any newline characters ('\n') within the text with regular
+ * whitespace (' '), ensuring that the contents of the text field are presented in a single line.
+ * @param onTextLayout Callback that is executed when a new text layout is calculated. A
+ * [TextLayoutResult] object contains paragraph information, size of the text, baselines and other
+ * details. The callback can be used to add additional decoration or functionality to the text.
+ * For example, to draw a cursor or selection around the text. [Density] scope is the one that was
+ * used while creating the given text layout.
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this TextField. You can create and pass in your own remembered [MutableInteractionSource]
+ * if you want to observe [Interaction]s and customize the appearance / behavior of this TextField
+ * for different [Interaction]s.
+ * @param cursorBrush [Brush] to paint cursor with. If [SolidColor] with [Color.Unspecified]
+ * provided, then no cursor will be drawn.
+ * @param scrollState Scroll state that manages either horizontal or vertical scroll of TextField.
+ * If [lineLimits] is [SingleLine], this text field is treated as single line with horizontal
+ * scroll behavior. In other cases the text field becomes vertically scrollable.
+ * @param codepointTransformation Visual transformation interface that provides a 1-to-1 mapping of
+ * codepoints.
+ * @param decorationBox Composable lambda that allows to add decorations around text field, such
+ * as icon, placeholder, helper messages or similar, and automatically increase the hit target area
+ * of the text field. To allow you to control the placement of the inner text field relative to your
+ * decorations, the text field implementation will pass in a framework-controlled composable
+ * parameter "innerTextField" to the decorationBox lambda you provide. You must call
+ * innerTextField exactly once.
+ */
+@ExperimentalFoundationApi
+@Composable
+fun BasicTextField2(
+    value: TextFieldValue,
+    onValueChange: (TextFieldValue) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    readOnly: Boolean = false,
+    filter: TextEditFilter? = null,
+    textStyle: TextStyle = TextStyle.Default,
+    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+    keyboardActions: KeyboardActions = KeyboardActions.Default,
+    lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
+    onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit = {},
+    interactionSource: MutableInteractionSource? = null,
+    cursorBrush: Brush = SolidColor(Color.Black),
+    scrollState: ScrollState = rememberScrollState(),
+    codepointTransformation: CodepointTransformation? = null,
+    decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
+        @Composable { innerTextField -> innerTextField() }
+) {
+    val state = remember {
+        TextFieldState(
+            initialText = value.text,
+            initialSelectionInChars = value.selection
+        )
+    }
+
+    BasicTextField2(
+        state = state,
+        modifier = modifier.syncTextFieldState(
+            state = state,
+            value = value,
+            onValueChanged = onValueChange,
+            writeSelectionFromTextFieldValue = true
+        ),
+        enabled = enabled,
+        readOnly = readOnly,
+        filter = filter,
+        textStyle = textStyle,
+        keyboardOptions = keyboardOptions,
+        keyboardActions = keyboardActions,
+        lineLimits = lineLimits,
+        onTextLayout = onTextLayout,
+        interactionSource = interactionSource,
+        cursorBrush = cursorBrush,
+        scrollState = scrollState,
+        codepointTransformation = codepointTransformation,
+        decorationBox = decorationBox,
+    )
+}
+
+/**
+ * BasicTextField2 is a new text input Composable under heavy development. Please refrain from
+ * using it in production since it has a very unstable API and implementation for the time being.
+ * Many core features like selection, cursor, gestures, etc. may fail or simply not exist.
+ *
+ * Basic text composable that provides an interactive box that accepts text input through software
+ * or hardware keyboard.
+ *
  * All the editing state of this composable is hoisted through [state]. Whenever the contents of
  * this composable change via user input or semantics, [TextFieldState.text] gets updated.
  * Similarly, all the programmatic updates made to [state] also reflect on this composable.
@@ -141,7 +416,7 @@
     keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
     keyboardActions: KeyboardActions = KeyboardActions.Default,
     lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
-    onTextLayout: Density.(TextLayoutResult) -> Unit = {},
+    onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit = {},
     interactionSource: MutableInteractionSource? = null,
     cursorBrush: Brush = SolidColor(Color.Black),
     scrollState: ScrollState = rememberScrollState(),
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldBuffer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldBuffer.kt
index 81e1aae..9d7bbac 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldBuffer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldBuffer.kt
@@ -129,6 +129,16 @@
         buffer.replace(start, end, text)
     }
 
+    /**
+     * Similar to `replace(0, length, newText)` but only records a change if [newText] is actually
+     * different from the current buffer value.
+     */
+    internal fun setTextIfChanged(newText: String) {
+        if (!buffer.contentEquals(newText)) {
+            replace(0, length, newText)
+        }
+    }
+
     // Doc inherited from Appendable.
     // This append overload should be first so it ends up being the target of links to this method.
     override fun append(text: CharSequence?): Appendable = apply {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
index 5a3ec3b..31d8246 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/TextFieldState.kt
@@ -26,9 +26,17 @@
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 
+internal fun TextFieldState(initialValue: TextFieldValue): TextFieldState {
+    return TextFieldState(
+        initialText = initialValue.text,
+        initialSelectionInChars = initialValue.selection
+    )
+}
+
 /**
  * The editable text state of a text field, including both the [text] itself and position of the
  * cursor or selection.
@@ -97,11 +105,20 @@
     internal fun startEdit(value: TextFieldCharSequence): TextFieldBuffer =
         TextFieldBuffer(value)
 
+    /**
+     * If the text or selection in [newValue] was actually modified, updates this state's internal
+     * values. If [newValue] was not modified at all, the state is not updated, and this will not
+     * invalidate anyone who is observing this state.
+     */
     @Suppress("ShowingMemberInHiddenClass")
     @PublishedApi
     internal fun commitEdit(newValue: TextFieldBuffer) {
-        val finalValue = newValue.toTextFieldCharSequence()
-        editProcessor.reset(finalValue)
+        val textChanged = newValue.changes.changeCount > 0
+        val selectionChanged = newValue.selectionInChars != editProcessor.mBuffer.selection
+        if (textChanged || selectionChanged) {
+            val finalValue = newValue.toTextFieldCharSequence()
+            editProcessor.reset(finalValue)
+        }
     }
 
     /**
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/GapBuffer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/GapBuffer.kt
index 01c13aa..64d4653 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/GapBuffer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/GapBuffer.kt
@@ -305,4 +305,11 @@
         sb.append(text, bufEnd, text.length)
         return sb.toString()
     }
+
+    /**
+     * Compares the contents of this buffer with the contents of [other].
+     */
+    fun contentEquals(other: CharSequence): Boolean {
+        return toString() == other.toString()
+    }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/StateSyncingModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/StateSyncingModifier.kt
new file mode 100644
index 0000000..72bd889
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/StateSyncingModifier.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.internal
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text2.BasicTextField2
+import androidx.compose.foundation.text2.input.TextFieldCharSequence
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusEventModifierNode
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.ObserverModifierNode
+import androidx.compose.ui.node.observeReads
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.text.input.TextFieldValue
+
+/**
+ * Synchronizes between [TextFieldState], immutable values, and value change callbacks for
+ * [BasicTextField2] overloads that take a value+callback for state instead of taking a
+ * [TextFieldState] directly. Effectively a fancy `rememberUpdatedState`.
+ *
+ * Only intended for use from [BasicTextField2].
+ *
+ * @param writeSelectionFromTextFieldValue If true, [update] will synchronize the selection from the
+ * [TextFieldValue] to the [TextFieldState]. The text will be synchronized regardless.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal fun Modifier.syncTextFieldState(
+    state: TextFieldState,
+    value: TextFieldValue,
+    onValueChanged: (TextFieldValue) -> Unit,
+    writeSelectionFromTextFieldValue: Boolean,
+): Modifier = this.then(
+    StateSyncingModifier(
+        state = state,
+        value = value,
+        onValueChanged = onValueChanged,
+        writeSelectionFromTextFieldValue = writeSelectionFromTextFieldValue
+    )
+)
+
+@OptIn(ExperimentalFoundationApi::class)
+private class StateSyncingModifier(
+    private val state: TextFieldState,
+    private val value: TextFieldValue,
+    private val onValueChanged: (TextFieldValue) -> Unit,
+    private val writeSelectionFromTextFieldValue: Boolean,
+) : ModifierNodeElement<StateSyncingModifierNode>() {
+
+    override fun create(): StateSyncingModifierNode =
+        StateSyncingModifierNode(state, onValueChanged, writeSelectionFromTextFieldValue)
+
+    override fun update(node: StateSyncingModifierNode) {
+        node.update(value, onValueChanged)
+    }
+
+    override fun equals(other: Any?): Boolean {
+        // Always call update, without comparing the text. Update can compare more efficiently.
+        return false
+    }
+
+    override fun hashCode(): Int {
+        // Avoid calculating hash from values that can change on every recomposition.
+        return state.hashCode()
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        // no inspector properties
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+private class StateSyncingModifierNode(
+    private val state: TextFieldState,
+    onValueChanged: (TextFieldValue) -> Unit,
+    private val writeSelectionFromTextFieldValue: Boolean,
+) : Modifier.Node(), ObserverModifierNode, FocusEventModifierNode {
+
+    private var onValueChanged = onValueChanged
+    private var isFocused = false
+    private var lastValueWhileFocused: TextFieldValue? = null
+
+    override val shouldAutoInvalidate: Boolean
+        get() = false
+
+    /**
+     * Synchronizes the latest [value] to the [TextFieldState] and updates our [onValueChanged]
+     * callback. Should be called from [ModifierNodeElement.update].
+     */
+    fun update(value: TextFieldValue, onValueChanged: (TextFieldValue) -> Unit) {
+        this.onValueChanged = onValueChanged
+
+        // Don't modify the text programmatically while an edit session is in progress.
+        // WARNING: While editing, the code that holds the external state is temporarily not the
+        // actual source of truth. This "stealing" of control is generally an anti-pattern. We do it
+        // intentionally here because text field state is very sensitive to timing, and if a state
+        // update is delivered a frame late, it breaks text input. It is very easy to accidentally
+        // introduce small bits of asynchrony in real-world scenarios, e.g. with Flow-based reactive
+        // architectures. The benefit of avoiding that easy pitfall outweighs the weirdness in this
+        // case.
+        if (!isFocused) {
+            updateState(value)
+        } else {
+            this.lastValueWhileFocused = value
+        }
+    }
+
+    override fun onAttach() {
+        // Don't fire the callback on first frame.
+        observeTextState(fireOnValueChanged = false)
+    }
+
+    override fun onFocusEvent(focusState: FocusState) {
+        if (this.isFocused && !focusState.isFocused) {
+            // Lost focus, perform deferred synchronization.
+            lastValueWhileFocused?.let(::updateState)
+            lastValueWhileFocused = null
+        }
+        this.isFocused = focusState.isFocused
+    }
+
+    /** Called by the modifier system when the [TextFieldState] has changed. */
+    override fun onObservedReadsChanged() {
+        observeTextState()
+    }
+
+    private fun updateState(value: TextFieldValue) {
+        state.edit {
+            // Avoid registering a state change if the text isn't actually different.
+            setTextIfChanged(value.text)
+
+            // The BasicTextField2(String) variant can't push a selection value, so ignore it.
+            if (writeSelectionFromTextFieldValue) {
+                selectCharsIn(value.selection)
+            }
+        }
+    }
+
+    private fun observeTextState(fireOnValueChanged: Boolean = true) {
+        lateinit var text: TextFieldCharSequence
+        observeReads {
+            text = state.text
+        }
+
+        // This code is outside of the observeReads lambda so we don't observe any state reads the
+        // callback happens to do.
+        if (fireOnValueChanged) {
+            val newValue = TextFieldValue(
+                text = text.toString(),
+                selection = text.selectionInChars,
+                composition = text.compositionInChars
+            )
+            onValueChanged(newValue)
+        }
+    }
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldDecoratorModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldDecoratorModifier.kt
index d389473b..6adb1ea 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldDecoratorModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldDecoratorModifier.kt
@@ -20,7 +20,6 @@
 import androidx.compose.foundation.text.KeyboardActionScope
 import androidx.compose.foundation.text.KeyboardActions
 import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.cancelsTextSelection
 import androidx.compose.foundation.text2.BasicTextField2
 import androidx.compose.foundation.text2.input.TextEditFilter
 import androidx.compose.foundation.text2.input.TextFieldState
@@ -170,7 +169,7 @@
      * Manages key events. These events often are sourced by a hardware keyboard but it's also
      * possible that IME or some other platform system simulates a KeyEvent.
      */
-    private val textFieldKeyEventHandler = TextFieldKeyEventHandler().also {
+    private val textFieldKeyEventHandler = createTextFieldKeyEventHandler().also {
         it.setFilter(filter)
     }
 
@@ -414,20 +413,21 @@
     }
 
     override fun onPreKeyEvent(event: KeyEvent): Boolean {
-        val selection = textFieldState.text.selectionInChars
-        return if (!selection.collapsed && event.cancelsTextSelection()) {
-            textFieldSelectionState.deselect()
-            true
-        } else {
-            false
-        }
+        return textFieldKeyEventHandler.onPreKeyEvent(
+            event = event,
+            textFieldState = textFieldState,
+            textFieldSelectionState = textFieldSelectionState,
+            focusManager = currentValueOf(LocalFocusManager),
+            keyboardController = requireKeyboardController()
+        )
     }
 
     override fun onKeyEvent(event: KeyEvent): Boolean {
         return textFieldKeyEventHandler.onKeyEvent(
             event = event,
-            state = textFieldState,
+            textFieldState = textFieldState,
             textLayoutState = textLayoutState,
+            textFieldSelectionState = textFieldSelectionState,
             editable = enabled && !readOnly,
             singleLine = singleLine,
             onSubmit = { onImeActionPerformed(keyboardOptions.imeAction) }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.kt
index b4e2481..5d1762e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.kt
@@ -20,22 +20,34 @@
 import androidx.compose.foundation.text.DeadKeyCombiner
 import androidx.compose.foundation.text.KeyCommand
 import androidx.compose.foundation.text.appendCodePointX
+import androidx.compose.foundation.text.cancelsTextSelection
 import androidx.compose.foundation.text.isTypedEvent
 import androidx.compose.foundation.text.platformDefaultKeyMapping
 import androidx.compose.foundation.text.showCharacterPalette
 import androidx.compose.foundation.text2.input.TextEditFilter
 import androidx.compose.foundation.text2.input.TextFieldState
 import androidx.compose.foundation.text2.input.internal.TextFieldPreparedSelection.Companion.NoCharacterFound
+import androidx.compose.foundation.text2.selection.TextFieldSelectionState
+import androidx.compose.ui.focus.FocusManager
 import androidx.compose.ui.input.key.KeyEvent
 import androidx.compose.ui.input.key.KeyEventType
 import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.SoftwareKeyboardController
 
 /**
- * Handles KeyEvents coming to a BasicTextField. This is mostly to support hardware keyboard but
+ * Factory function to create a platform specific [TextFieldKeyEventHandler].
+ */
+internal expect fun createTextFieldKeyEventHandler(): TextFieldKeyEventHandler
+
+/**
+ * Handles KeyEvents coming to a BasicTextField2. This is mostly to support hardware keyboard but
  * any KeyEvent can also be sent by the IME or other platform systems.
+ *
+ * This class is left abstract to make sure that each platform extends from it. Platforms can
+ * decide to extend or completely override KeyEvent actions defined here.
  */
 @OptIn(ExperimentalFoundationApi::class)
-internal class TextFieldKeyEventHandler {
+internal abstract class TextFieldKeyEventHandler {
     private val preparedSelectionState = TextFieldPreparedSelectionState()
     private val deadKeyCombiner = DeadKeyCombiner()
     private val keyMapping = platformDefaultKeyMapping
@@ -45,10 +57,27 @@
         this.filter = filter
     }
 
-    fun onKeyEvent(
+    open fun onPreKeyEvent(
         event: KeyEvent,
-        state: TextFieldState,
+        textFieldState: TextFieldState,
+        textFieldSelectionState: TextFieldSelectionState,
+        focusManager: FocusManager,
+        keyboardController: SoftwareKeyboardController
+    ): Boolean {
+        val selection = textFieldState.text.selectionInChars
+        return if (!selection.collapsed && event.cancelsTextSelection()) {
+            textFieldSelectionState.deselect()
+            true
+        } else {
+            false
+        }
+    }
+
+    open fun onKeyEvent(
+        event: KeyEvent,
+        textFieldState: TextFieldState,
         textLayoutState: TextLayoutState,
+        textFieldSelectionState: TextFieldSelectionState,
         editable: Boolean,
         singleLine: Boolean,
         onSubmit: () -> Unit
@@ -59,7 +88,7 @@
         val editCommand = event.toTypedEditCommand()
         if (editCommand != null) {
             return if (editable) {
-                editCommand.applyOnto(state)
+                editCommand.applyOnto(textFieldState)
                 preparedSelectionState.resetCachedX()
                 true
             } else {
@@ -71,13 +100,11 @@
             return false
         }
         var consumed = true
-        preparedSelectionContext(state, textLayoutState) {
+        preparedSelectionContext(textFieldState, textLayoutState) {
             when (command) {
-                // TODO(halilibo): implement after selection is supported.
-                KeyCommand.COPY, // -> selectionManager.copy(false)
-                    // TODO(siyamed): cut & paste will cause a reset input
-                KeyCommand.PASTE, // -> selectionManager.paste()
-                KeyCommand.CUT -> moveCursorRight() // selectionManager.cut()
+                KeyCommand.COPY -> textFieldSelectionState.copy(false)
+                KeyCommand.PASTE -> textFieldSelectionState.paste()
+                KeyCommand.CUT -> textFieldSelectionState.cut()
                 KeyCommand.LEFT_CHAR -> collapseLeftOr { moveCursorLeft() }
                 KeyCommand.RIGHT_CHAR -> collapseRightOr { moveCursorRight() }
                 KeyCommand.LEFT_WORD -> moveCursorLeftByWord()
@@ -100,7 +127,7 @@
                             selection.end - getPrecedingCharacterIndex(),
                             0
                         )
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
 
                 KeyCommand.DELETE_NEXT_CHAR -> {
                     // Note that some software keyboards, such as Samsungs, go through this code
@@ -114,7 +141,7 @@
                         } else {
                             null
                         }
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
                 }
 
                 KeyCommand.DELETE_PREV_WORD ->
@@ -122,39 +149,39 @@
                         getPreviousWordOffset()?.let {
                             DeleteSurroundingTextCommand(selection.end - it, 0)
                         }
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
 
                 KeyCommand.DELETE_NEXT_WORD ->
                     deleteIfSelectedOr {
                         getNextWordOffset()?.let {
                             DeleteSurroundingTextCommand(0, it - selection.end)
                         }
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
 
                 KeyCommand.DELETE_FROM_LINE_START ->
                     deleteIfSelectedOr {
                         getLineStartByOffset()?.let {
                             DeleteSurroundingTextCommand(selection.end - it, 0)
                         }
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
 
                 KeyCommand.DELETE_TO_LINE_END ->
                     deleteIfSelectedOr {
                         getLineEndByOffset()?.let {
                             DeleteSurroundingTextCommand(0, it - selection.end)
                         }
-                    }?.applyOnto(state)
+                    }?.applyOnto(textFieldState)
 
                 KeyCommand.NEW_LINE ->
                     if (!singleLine) {
-                        CommitTextCommand("\n", 1).applyOnto(state)
+                        CommitTextCommand("\n", 1).applyOnto(textFieldState)
                     } else {
                         onSubmit()
                     }
 
                 KeyCommand.TAB ->
                     if (!singleLine) {
-                        CommitTextCommand("\t", 1).applyOnto(state)
+                        CommitTextCommand("\t", 1).applyOnto(textFieldState)
                     } else {
                         consumed = false // let propagate to focus system
                     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCache.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCache.kt
new file mode 100644
index 0000000..6557d6f
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldLayoutStateCache.kt
@@ -0,0 +1,438 @@
+/*
+ * 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.internal
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text.InternalFoundationTextApi
+import androidx.compose.foundation.text.TextDelegate
+import androidx.compose.foundation.text2.input.CodepointTransformation
+import androidx.compose.foundation.text2.input.SingleLineCodepointTransformation
+import androidx.compose.foundation.text2.input.TextFieldCharSequence
+import androidx.compose.foundation.text2.input.TextFieldState
+import androidx.compose.foundation.text2.input.internal.TextFieldLayoutStateCache.MeasureInputs
+import androidx.compose.foundation.text2.input.internal.TextFieldLayoutStateCache.NonMeasureInputs
+import androidx.compose.foundation.text2.input.toVisualText
+import androidx.compose.runtime.SnapshotMutationPolicy
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.runtime.snapshots.StateObject
+import androidx.compose.runtime.snapshots.StateRecord
+import androidx.compose.runtime.snapshots.withCurrent
+import androidx.compose.runtime.snapshots.writable
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * Performs text layout lazily, on-demand for text fields with snapshot-aware caching.
+ *
+ * You can basically think of this as a `derivedStateOf` that combines all the inputs to text layout
+ * — the text itself, configuration parameters, and layout inputs — and spits out a
+ * [TextLayoutResult]. The [value] property will register snapshot reads for all the inputs and
+ * either return a cached result or re-compute the result and cache it in the current snapshot.
+ * The cache is snapshot aware: when a new layout is computed, it will only be cached in the current
+ * snapshot. When the snapshot with the new result is applied, its cache will also be visible to the
+ * parent snapshot.
+ *
+ * All the possible inputs to text layout are grouped into two groups: those that come from the
+ * layout system ([MeasureInputs]) and those that are passed explicitly to the text field composable
+ * ([NonMeasureInputs]). Each of these groups can only be updated in bulk, and each group is stored
+ * in an instance of a dedicated class. This means for each type of update, only one state object
+ * is needed.
+ */
+@OptIn(ExperimentalFoundationApi::class, InternalFoundationTextApi::class)
+internal class TextFieldLayoutStateCache : State<TextLayoutResult?>, StateObject {
+    private var nonMeasureInputs: NonMeasureInputs? by mutableStateOf(
+        value = null,
+        policy = NonMeasureInputs.mutationPolicy
+    )
+    private var measureInputs: MeasureInputs? by mutableStateOf(
+        value = null,
+        policy = MeasureInputs.mutationPolicy
+    )
+
+    /**
+     * Returns the [TextLayoutResult] for the current text field state and layout inputs, or null
+     * if the layout cannot be computed at this time.
+     *
+     * This method will re-calculate the text layout if the text or any of the other layout inputs
+     * have changed, otherwise it will return a cached value.
+     *
+     * [updateNonMeasureInputs] and [layoutWithNewMeasureInputs] must both be called before this
+     * to initialize all the inputs, or it will return null.
+     */
+    override val value: TextLayoutResult?
+        get() {
+            // If this is called from the global snapshot, there is technically a race between
+            // reading each of our input state objects. That's fine because worst case we'll just
+            // re-compute the layout on the next read anyway.
+            val nonMeasureInputs = nonMeasureInputs ?: return null
+            val measureInputs = measureInputs ?: return null
+            return getOrComputeLayout(nonMeasureInputs, measureInputs)
+        }
+
+    /**
+     * Updates the inputs that aren't from the measure phase.
+     *
+     * If any of the inputs changed, this method will invalidate any callers of [value]. If the
+     * inputs did not change it will not invalidate callers of [value].
+     *
+     * Note: This will register a snapshot read of [TextFieldState.text] if called from a snapshot
+     * observer.
+     *
+     * @see layoutWithNewMeasureInputs
+     */
+    fun updateNonMeasureInputs(
+        textFieldState: TextFieldState,
+        codepointTransformation: CodepointTransformation?,
+        textStyle: TextStyle,
+        singleLine: Boolean,
+        softWrap: Boolean,
+    ) {
+        nonMeasureInputs = NonMeasureInputs(
+            textFieldState = textFieldState,
+            codepointTransformation = codepointTransformation,
+            textStyle = textStyle,
+            singleLine = singleLine,
+            softWrap = softWrap,
+        )
+    }
+
+    /**
+     * Updates the inputs from the measure phase and returns the most up-to-date [TextLayoutResult].
+     *
+     * If any of the inputs changed, this method will invalidate any callers of [value], re-compute
+     * the text layout, and return the new layout result. If the inputs did not change, it will
+     * return a cached value without invalidating callers of [value].
+     *
+     * @see updateNonMeasureInputs
+     */
+    fun layoutWithNewMeasureInputs(
+        density: Density,
+        layoutDirection: LayoutDirection,
+        fontFamilyResolver: FontFamily.Resolver,
+        constraints: Constraints,
+    ): TextLayoutResult {
+        val measureInputs = MeasureInputs(
+            density = density,
+            layoutDirection = layoutDirection,
+            fontFamilyResolver = fontFamilyResolver,
+            constraints = constraints,
+        )
+        this.measureInputs = measureInputs
+        val nonMeasureInputs = checkNotNull(nonMeasureInputs) {
+            "Called layoutWithNewMeasureInputs before updateNonMeasureInputs"
+        }
+        return getOrComputeLayout(nonMeasureInputs, measureInputs)
+    }
+
+    private fun getOrComputeLayout(
+        nonMeasureInputs: NonMeasureInputs,
+        measureInputs: MeasureInputs
+    ): TextLayoutResult {
+        // This is the state read that will invalidate us when the text is changed but nothing else.
+        val untransformedText = nonMeasureInputs.textFieldState.text
+
+        // Use withCurrent here so the cache itself is never reported as a read state object. It
+        // doesn't need to be, because it's always guaranteed to return the same value for the same
+        // inputs, so it's good enough to read the input states and those will invalidate the
+        // caller when they change.
+        record.withCurrent { cachedRecord ->
+            val cachedResult = cachedRecord.layoutResult
+            val textChanged =
+                cachedRecord.untransformedText?.contentEquals(untransformedText) != true ||
+                    cachedRecord.codepointTransformation != nonMeasureInputs.codepointTransformation
+            val otherParamsChanged = cachedRecord.singleLine != nonMeasureInputs.singleLine ||
+                cachedRecord.softWrap != nonMeasureInputs.softWrap ||
+                cachedRecord.textStyle
+                    ?.hasSameLayoutAffectingAttributes(nonMeasureInputs.textStyle) != true ||
+                cachedRecord.layoutDirection != measureInputs.layoutDirection ||
+                cachedRecord.densityValue != measureInputs.density.density ||
+                cachedRecord.fontScale != measureInputs.density.fontScale ||
+                cachedRecord.constraints != measureInputs.constraints ||
+                cachedRecord.fontFamilyResolver != measureInputs.fontFamilyResolver
+
+            if (cachedResult != null && !textChanged && !otherParamsChanged) {
+                // Fast path: None of the inputs changed.
+                return cachedResult
+            }
+
+            // First prefer provided codepointTransformation if not null, e.g.
+            // BasicSecureTextField would send Password Transformation.
+            // Second, apply a SingleLineCodepointTransformation if text field is configured
+            // to be single line.
+            // Else, don't apply any visual transformation.
+            val appliedCodepointTransformation = nonMeasureInputs.codepointTransformation
+                ?: SingleLineCodepointTransformation.takeIf { nonMeasureInputs.singleLine }
+            val visualText = untransformedText.toVisualText(appliedCodepointTransformation)
+
+            if (cachedResult != null &&
+                !otherParamsChanged &&
+                cachedRecord.visualText.contentEquals(visualText)
+            ) {
+                // Medium path: If text changed or codepoint transformation changed, but the end
+                // result of transformation is the same, use cached result. Also update the cache
+                // so we don't have to re-transform the text again next time.
+                updateCacheIfWritable {
+                    this.untransformedText = untransformedText
+                    this.codepointTransformation = nonMeasureInputs.codepointTransformation
+                }
+                return cachedResult
+            }
+
+            // Slow path: Some input changed, need to re-layout.
+            return computeLayout(visualText, nonMeasureInputs, measureInputs, cachedResult)
+                .also { newResult ->
+                    // TODO(b/294403840) TextDelegate does its own caching and may return the same
+                    //  TextLayoutResult object. We should inline that so we don't check twice.
+                    if (newResult != cachedResult) {
+                        updateCacheIfWritable {
+                            this.untransformedText = untransformedText
+                            this.visualText = visualText
+                            this.codepointTransformation = nonMeasureInputs.codepointTransformation
+                            this.singleLine = nonMeasureInputs.singleLine
+                            this.softWrap = nonMeasureInputs.softWrap
+                            this.textStyle = nonMeasureInputs.textStyle
+                            this.layoutDirection = measureInputs.layoutDirection
+                            this.densityValue = measureInputs.densityValue
+                            this.fontScale = measureInputs.fontScale
+                            this.constraints = measureInputs.constraints
+                            this.fontFamilyResolver = measureInputs.fontFamilyResolver
+                            this.layoutResult = newResult
+                        }
+                    }
+                }
+        }
+    }
+
+    private inline fun updateCacheIfWritable(block: CacheRecord.() -> Unit) {
+        val snapshot = Snapshot.current
+        // We can't write to the cache when called from a read-only snapshot.
+        if (!snapshot.readOnly) {
+            record.writable(this, snapshot, block)
+        }
+    }
+
+    private fun computeLayout(
+        visualText: CharSequence,
+        nonMeasureInputs: NonMeasureInputs,
+        measureInputs: MeasureInputs,
+        prevResult: TextLayoutResult?
+    ): TextLayoutResult {
+        // TODO(b/294403840) Don't use TextDelegate – it is not designed for this use case,
+        //  optimized for re-use which we don't take advantage of here, and does its own caching
+        //  checks. Maybe we can use MultiParagraphLayoutCache like BasicText?
+
+        // We have to always create a new TextDelegate since it contains internal state that is
+        // not snapshot-aware.
+        val textDelegate = TextDelegate(
+            text = AnnotatedString(visualText.toString()),
+            style = nonMeasureInputs.textStyle,
+            density = measureInputs.density,
+            fontFamilyResolver = measureInputs.fontFamilyResolver,
+            softWrap = nonMeasureInputs.softWrap,
+            placeholders = emptyList()
+        )
+
+        return textDelegate.layout(
+            layoutDirection = measureInputs.layoutDirection,
+            constraints = measureInputs.constraints,
+            prevResult = prevResult
+        )
+    }
+
+    // region StateObject
+    private var record = CacheRecord()
+    override val firstStateRecord: StateRecord
+        get() = record
+
+    override fun prependStateRecord(value: StateRecord) {
+        this.record = value as CacheRecord
+    }
+
+    override fun mergeRecords(
+        previous: StateRecord,
+        current: StateRecord,
+        applied: StateRecord
+    ): StateRecord {
+        // This is just a cache, so it's safe to always take the most recent record – worst case
+        // we'll just re-compute the layout.
+        // However, if we needed to, we could increase the chance of a cache hit by comparing
+        // property-by-property and taking the latest version of each property.
+        return applied
+    }
+
+    /**
+     * State record that stores the cached [TextLayoutResult], as well as all the inputs used to
+     * generate that result.
+     */
+    private class CacheRecord : StateRecord() {
+        // Inputs. These are slightly different from the values in (Non)MeasuredInputs because they
+        // represent the values read from objects in the inputs that are relevant to layout, whereas
+        // the Inputs classes contain objects where we don't always care about the entire object.
+        // E.g. text layout doesn't care about TextFieldState instances, it only cares about the
+        // actual text. If the TFS instance changes but has the same text, we don't need to
+        // re-layout. Also if the TFS object _doesn't_ change but its text _does_, we do need to
+        // re-layout. That state read happens in getOrComputeLayout to invalidate correctly.
+        /** The text before being ran through [codepointTransformation]. */
+        var untransformedText: TextFieldCharSequence? = null
+        var codepointTransformation: CodepointTransformation? = null
+
+        /** The text after [codepointTransformation]. */
+        var visualText: CharSequence? = null
+        var textStyle: TextStyle? = null
+        var singleLine: Boolean = false
+        var softWrap: Boolean = false
+        var densityValue: Float = Float.NaN
+        var fontScale: Float = Float.NaN
+        var layoutDirection: LayoutDirection? = null
+        var fontFamilyResolver: FontFamily.Resolver? = null
+
+        /** Not nullable to avoid boxing. */
+        var constraints: Constraints = Constraints()
+
+        // Outputs.
+        var layoutResult: TextLayoutResult? = null
+
+        override fun create(): StateRecord = CacheRecord()
+
+        override fun assign(value: StateRecord) {
+            value as CacheRecord
+            untransformedText = value.untransformedText
+            codepointTransformation = value.codepointTransformation
+            visualText = value.visualText
+            textStyle = value.textStyle
+            singleLine = value.singleLine
+            softWrap = value.softWrap
+            densityValue = value.densityValue
+            fontScale = value.fontScale
+            layoutDirection = value.layoutDirection
+            fontFamilyResolver = value.fontFamilyResolver
+            constraints = value.constraints
+            layoutResult = value.layoutResult
+        }
+
+        override fun toString(): String = "CacheRecord(" +
+            "untransformedText=$untransformedText, " +
+            "codepointTransformation=$codepointTransformation, " +
+            "visualText=$visualText, " +
+            "textStyle=$textStyle, " +
+            "singleLine=$singleLine, " +
+            "softWrap=$softWrap, " +
+            "densityValue=$densityValue, " +
+            "fontScale=$fontScale, " +
+            "layoutDirection=$layoutDirection, " +
+            "fontFamilyResolver=$fontFamilyResolver, " +
+            "constraints=$constraints, " +
+            "layoutResult=$layoutResult" +
+            ")"
+    }
+    // endregion
+
+    // region Input holders
+    private class NonMeasureInputs(
+        val textFieldState: TextFieldState,
+        val codepointTransformation: CodepointTransformation?,
+        val textStyle: TextStyle,
+        val singleLine: Boolean,
+        val softWrap: Boolean,
+    ) {
+
+        override fun toString(): String = "NonMeasureInputs(" +
+            "textFieldState=$textFieldState, " +
+            "codepointTransformation=$codepointTransformation, " +
+            "textStyle=$textStyle, " +
+            "singleLine=$singleLine, " +
+            "softWrap=$softWrap" +
+            ")"
+
+        companion object {
+            /**
+             * Implements equivalence by comparing only the parts of [NonMeasureInputs] that may
+             * require re-computing text layout. Notably, it reads the [TextFieldState.text] state
+             * property and compares only the text (not selection). This means that when the text
+             * state changes it will invalidate any snapshot observer that sets this state.
+             */
+            val mutationPolicy = object : SnapshotMutationPolicy<NonMeasureInputs?> {
+                override fun equivalent(a: NonMeasureInputs?, b: NonMeasureInputs?): Boolean =
+                    if (a != null && b != null) {
+                        // We don't need to compare text contents here because the text state is read
+                        // by getOrComputeLayout – if the text state changes, that method will already
+                        // be invalidated. The only reason to compare text here would be to avoid
+                        // invalidating if the TextFieldState is a different instance but with the same
+                        // text, but that is unlikely to happen.
+                        a.textFieldState === b.textFieldState &&
+                            a.codepointTransformation == b.codepointTransformation &&
+                            a.textStyle.hasSameLayoutAffectingAttributes(b.textStyle) &&
+                            a.singleLine == b.singleLine &&
+                            a.softWrap == b.softWrap
+                    } else {
+                        !((a == null) xor (b == null))
+                    }
+            }
+        }
+    }
+
+    /**
+     * We store both the [Density] object, as well as its component values, because the same density
+     * object can report different actual densities over time so we need to be able to see when
+     * those values change. We still need the [Density] object to pass to [TextDelegate] though.
+     */
+    private class MeasureInputs(
+        val density: Density,
+        val layoutDirection: LayoutDirection,
+        val fontFamilyResolver: FontFamily.Resolver,
+        val constraints: Constraints,
+    ) {
+        val densityValue: Float = density.density
+        val fontScale: Float = density.fontScale
+
+        override fun toString(): String = "MeasureInputs(" +
+            "density=$density, " +
+            "densityValue=$densityValue, " +
+            "fontScale=$fontScale, " +
+            "layoutDirection=$layoutDirection, " +
+            "fontFamilyResolver=$fontFamilyResolver, " +
+            "constraints=$constraints" +
+            ")"
+
+        companion object {
+            val mutationPolicy = object : SnapshotMutationPolicy<MeasureInputs?> {
+                override fun equivalent(a: MeasureInputs?, b: MeasureInputs?): Boolean =
+                    if (a != null && b != null) {
+                        // Don't compare density – we don't care if the density instance changed,
+                        // only if the actual values used in density calculations did.
+                        a.densityValue == b.densityValue &&
+                            a.fontScale == b.fontScale &&
+                            a.layoutDirection == b.layoutDirection &&
+                            a.fontFamilyResolver == b.fontFamilyResolver &&
+                            a.constraints == b.constraints
+                    } else {
+                        !((a == null) xor (b == null))
+                    }
+            }
+        }
+    }
+    // endregion
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldTextLayoutModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldTextLayoutModifier.kt
index 5ad9d39..ec07b7e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldTextLayoutModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldTextLayoutModifier.kt
@@ -18,9 +18,7 @@
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.text2.input.CodepointTransformation
-import androidx.compose.foundation.text2.input.SingleLineCodepointTransformation
 import androidx.compose.foundation.text2.input.TextFieldState
-import androidx.compose.foundation.text2.input.toVisualText
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.FirstBaseline
 import androidx.compose.ui.layout.LastBaseline
@@ -34,9 +32,7 @@
 import androidx.compose.ui.node.ModifierNodeElement
 import androidx.compose.ui.node.currentValueOf
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalFontFamilyResolver
-import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.Constraints
@@ -57,7 +53,7 @@
     private val codepointTransformation: CodepointTransformation?,
     private val textStyle: TextStyle,
     private val singleLine: Boolean,
-    private val onTextLayout: Density.(TextLayoutResult) -> Unit
+    private val onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit
 ) : ModifierNodeElement<TextFieldTextLayoutModifierNode>() {
     override fun create(): TextFieldTextLayoutModifierNode = TextFieldTextLayoutModifierNode(
         textLayoutState = textLayoutState,
@@ -87,15 +83,27 @@
 @OptIn(ExperimentalFoundationApi::class)
 internal class TextFieldTextLayoutModifierNode(
     private var textLayoutState: TextLayoutState,
-    private var textFieldState: TextFieldState,
-    private var codepointTransformation: CodepointTransformation?,
-    private var textStyle: TextStyle,
-    private var singleLine: Boolean,
-    private var onTextLayout: Density.(TextLayoutResult) -> Unit
+    textFieldState: TextFieldState,
+    codepointTransformation: CodepointTransformation?,
+    textStyle: TextStyle,
+    singleLine: Boolean,
+    onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit
 ) : Modifier.Node(),
     LayoutModifierNode,
     GlobalPositionAwareModifierNode,
     CompositionLocalConsumerModifierNode {
+
+    init {
+        textLayoutState.onTextLayout = onTextLayout
+        textLayoutState.updateNonMeasureInputs(
+            textFieldState = textFieldState,
+            codepointTransformation = codepointTransformation,
+            textStyle = textStyle,
+            singleLine = singleLine,
+            softWrap = !singleLine
+        )
+    }
+
     /**
      * Updates all the related properties and invalidates internal state based on the changes.
      */
@@ -105,14 +113,17 @@
         codepointTransformation: CodepointTransformation?,
         textStyle: TextStyle,
         singleLine: Boolean,
-        onTextLayout: Density.(TextLayoutResult) -> Unit
+        onTextLayout: Density.(getResult: () -> TextLayoutResult?) -> Unit
     ) {
         this.textLayoutState = textLayoutState
-        this.textFieldState = textFieldState
-        this.codepointTransformation = codepointTransformation
-        this.textStyle = textStyle
-        this.singleLine = singleLine
-        this.onTextLayout = onTextLayout
+        this.textLayoutState.onTextLayout = onTextLayout
+        this.textLayoutState.updateNonMeasureInputs(
+            textFieldState = textFieldState,
+            codepointTransformation = codepointTransformation,
+            textStyle = textStyle,
+            singleLine = singleLine,
+            softWrap = !singleLine
+        )
     }
 
     override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
@@ -123,27 +134,12 @@
         measurable: Measurable,
         constraints: Constraints
     ): MeasureResult {
-        val result = with(textLayoutState) {
-            // First prefer provided codepointTransformation if not null, e.g.
-            // BasicSecureTextField would send Password Transformation.
-            // Second, apply a SingleLineCodepointTransformation if text field is configured
-            // to be single line.
-            // Else, don't apply any visual transformation.
-            val appliedCodepointTransformation = codepointTransformation
-                ?: SingleLineCodepointTransformation.takeIf { singleLine }
-
-            val visualText = textFieldState.text.toVisualText(appliedCodepointTransformation)
-            // Composition Local reads are automatically tracked here because we are in layout
-            layout(
-                text = AnnotatedString(visualText.toString()),
-                textStyle = textStyle,
-                softWrap = !singleLine,
-                density = currentValueOf(LocalDensity),
-                fontFamilyResolver = currentValueOf(LocalFontFamilyResolver),
-                constraints = constraints,
-                onTextLayout = onTextLayout
-            )
-        }
+        val result = textLayoutState.layoutWithNewMeasureInputs(
+            density = this,
+            layoutDirection = layoutDirection,
+            fontFamilyResolver = currentValueOf(LocalFontFamilyResolver),
+            constraints = constraints,
+        )
 
         val placeable = measurable.measure(
             Constraints.fixed(result.size.width, result.size.height)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextLayoutState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextLayoutState.kt
index f9cf75a..991dae0 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextLayoutState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/TextLayoutState.kt
@@ -16,42 +16,34 @@
 
 package androidx.compose.foundation.text2.input.internal
 
-import androidx.compose.foundation.text.InternalFoundationTextApi
-import androidx.compose.foundation.text.TextDelegate
-import androidx.compose.foundation.text.updateTextDelegate
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.text2.input.CodepointTransformation
+import androidx.compose.foundation.text2.input.TextFieldState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.neverEqualPolicy
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.layout.LayoutCoordinates
-import androidx.compose.ui.layout.MeasureScope
-import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
 
 /**
  * Manages text layout for TextField including layout coordinates of decoration box and inner text
  * field.
  */
-@OptIn(InternalFoundationTextApi::class)
+@OptIn(ExperimentalFoundationApi::class)
 internal class TextLayoutState {
-    /**
-     * Set of parameters and an internal cache to compute text layout.
-     */
-    var textDelegate: TextDelegate? = null
-        private set
+    private var layoutCache = TextFieldLayoutStateCache()
 
-    /**
-     * Text Layout State.
-     */
-    var layoutResult: TextLayoutResult? by mutableStateOf(null)
-        private set
+    var onTextLayout: (Density.(() -> TextLayoutResult?) -> Unit)? = null
+
+    val layoutResult: TextLayoutResult? by layoutCache
 
     /** Measured bounds of the decoration box and inner text field. Together used to
      * calculate the relative touch offset. Because touches are applied on the decoration box, we
@@ -64,51 +56,57 @@
     var innerTextFieldCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
     var decorationBoxCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
 
-    fun MeasureScope.layout(
-        text: AnnotatedString,
+    /**
+     * Updates the [TextFieldLayoutStateCache] with inputs that don't come from the measure phase.
+     * This method will initialize the cache the first time it's called.
+     * If the new inputs require re-calculating text layout, any readers of [layoutResult] called
+     * from a snapshot observer will be invalidated.
+     *
+     * @see layoutWithNewMeasureInputs
+     */
+    fun updateNonMeasureInputs(
+        textFieldState: TextFieldState,
+        codepointTransformation: CodepointTransformation?,
         textStyle: TextStyle,
+        singleLine: Boolean,
         softWrap: Boolean,
+    ) {
+        layoutCache.updateNonMeasureInputs(
+            textFieldState = textFieldState,
+            codepointTransformation = codepointTransformation,
+            textStyle = textStyle,
+            singleLine = singleLine,
+            softWrap = softWrap,
+        )
+    }
+
+    /**
+     * Updates the [TextFieldLayoutStateCache] with inputs that come from the measure phase and returns the
+     * latest [TextLayoutResult]. If the measure inputs haven't changed significantly since the
+     * last call, this will be the cached result. If the new inputs require re-calculating text
+     * layout, any readers of [layoutResult] called from a snapshot observer will be invalidated.
+     *
+     * [updateNonMeasureInputs] must be called before this method to initialize the cache.
+     */
+    fun layoutWithNewMeasureInputs(
         density: Density,
+        layoutDirection: LayoutDirection,
         fontFamilyResolver: FontFamily.Resolver,
         constraints: Constraints,
-        onTextLayout: Density.(TextLayoutResult) -> Unit
     ): TextLayoutResult {
-        val prevResult = Snapshot.withoutReadObservation { layoutResult }
-
-        val currTextDelegate = textDelegate
-
-        val newTextDelegate = if (currTextDelegate != null) {
-            updateTextDelegate(
-                current = currTextDelegate,
-                text = text,
-                style = textStyle,
-                softWrap = softWrap,
-                density = density,
-                fontFamilyResolver = fontFamilyResolver,
-                placeholders = emptyList(),
-            )
-        } else {
-            TextDelegate(
-                text = text,
-                style = textStyle,
-                density = density,
-                fontFamilyResolver = fontFamilyResolver,
-                softWrap = true,
-                placeholders = emptyList()
-            )
-        }
-
-        return newTextDelegate.layout(
+        val layoutResult = layoutCache.layoutWithNewMeasureInputs(
+            density = density,
             layoutDirection = layoutDirection,
+            fontFamilyResolver = fontFamilyResolver,
             constraints = constraints,
-            prevResult = prevResult
-        ).also {
-            textDelegate = newTextDelegate
-            if (prevResult != it) {
-                onTextLayout(it)
-            }
-            layoutResult = it
+        )
+
+        onTextLayout?.let { onTextLayout ->
+            val textLayoutProvider = { layoutCache.value }
+            onTextLayout(density, textLayoutProvider)
         }
+
+        return layoutResult
     }
 
     /**
@@ -155,7 +153,7 @@
      * field coordinates. This relative position is then used to determine symbol position in
      * text using TextLayoutResult object.
      */
-    private fun Offset.relativeToInputText(): Offset {
+    fun Offset.relativeToInputText(): Offset {
         // Translates touch to the inner text field coordinates
         return innerTextFieldCoordinates?.let { innerTextFieldCoordinates ->
             decorationBoxCoordinates?.let { decorationBoxCoordinates ->
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifier.kt
index cfc5c9e..1f0af95 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldMagnifier.kt
@@ -25,6 +25,7 @@
 import androidx.compose.foundation.text2.input.internal.coerceIn
 import androidx.compose.foundation.text2.input.internal.fromInnerToDecoration
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.isUnspecified
 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.OnGloballyPositionedModifier
@@ -72,10 +73,13 @@
     magnifierSize: IntSize
 ): Offset {
     // state read of currentDragPosition so that we always recompose on drag position changes
-    val localDragPosition = selectionState.handleDragPosition ?: return Offset.Unspecified
+    val localDragPosition = selectionState.handleDragPosition
 
+    // Do not show the magnifier if origin position is already Unspecified.
     // Never show the magnifier in an empty text field.
-    if (textFieldState.text.isEmpty()) return Offset.Unspecified
+    if (localDragPosition.isUnspecified || textFieldState.text.isEmpty()) {
+        return Offset.Unspecified
+    }
 
     val selection = textFieldState.text.selectionInChars
     val textOffset = when (selectionState.draggingHandle) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
index 9f4b7f1..0c79d90 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
@@ -46,6 +46,7 @@
 import androidx.compose.runtime.structuralEqualityPolicy
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.isUnspecified
 import androidx.compose.ui.hapticfeedback.HapticFeedback
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.input.pointer.PointerEventPass
@@ -97,10 +98,54 @@
     var isInTouchMode: Boolean by mutableStateOf(true)
 
     /**
-     * Current drag position of a handle for magnifier to read. Only one handle can be dragged
-     * at one time.
+     * The offset of visible bounds when dragging is started by a cursor or a selection handle.
+     * Total drag value needs to account for any auto scrolling that happens during the scroll.
+     * This value is an anchor to calculate how much the visible bounds have shifted as the
+     * dragging continues. If a cursor or a selection handle is not dragging, this value needs to be
+     * [Offset.Unspecified]. This includes long press and drag gesture defined on TextField.
      */
-    var handleDragPosition by mutableStateOf<Offset?>(null)
+    private var startContentVisibleOffset by mutableStateOf(Offset.Unspecified)
+
+    /**
+     * Calculates the offset of currently visible bounds.
+     */
+    private val currentContentVisibleOffset: Offset
+        get() = innerCoordinates
+            ?.visibleBounds()
+            ?.topLeft ?: Offset.Unspecified
+
+    /**
+     * Current drag position of a handle for magnifier to read. Only one handle can be dragged
+     * at one time. This uses raw position as in only gesture start position and delta are used to
+     * calculate it. If a scroll is caused by the selection changes while the gesture is active,
+     * it is not reflected on this value. See [handleDragPosition] for such a  behavior.
+     */
+    private var rawHandleDragPosition by mutableStateOf(Offset.Unspecified)
+
+    /**
+     * Defines where the handle exactly is in inner text field coordinates. This is mainly used by
+     * Magnifier to anchor itself. Also, it provides an updated total drag value to cursor and
+     * selection handles to continue scrolling as they are dragged outside the visible bounds.
+     */
+    val handleDragPosition: Offset
+        get() {
+            return when {
+                // nothing is being dragged.
+                rawHandleDragPosition.isUnspecified -> {
+                    Offset.Unspecified
+                }
+                // no real handle is being dragged, we need to offset the drag position by current
+                // inner-decorator relative positioning.
+                startContentVisibleOffset.isUnspecified -> {
+                    with(textLayoutState) { rawHandleDragPosition.relativeToInputText() }
+                }
+                // a cursor or a selection handle is being dragged, offset by comparing the current
+                // and starting visible offsets.
+                else -> {
+                    rawHandleDragPosition + currentContentVisibleOffset - startContentVisibleOffset
+                }
+            }
+        }
 
     /**
      * Which selection handle is currently being dragged.
@@ -257,6 +302,7 @@
             launch(start = CoroutineStart.UNDISPATCHED) {
                 detectPressDownGesture(
                     onDown = {
+                        markStartContentVisibleOffset()
                         updateHandleDragging(
                             handle = if (isStartHandle) {
                                 Handle.SelectionStart
@@ -373,16 +419,12 @@
     }
 
     private suspend fun PointerInputScope.detectCursorHandleDragGestures() {
-        // keep track of how visible bounds change while moving the cursor handle.
-        var startContentVisibleOffset: Offset = Offset.Zero
-
         var cursorDragStart = Offset.Unspecified
         var cursorDragDelta = Offset.Unspecified
 
         fun onDragStop() {
             cursorDragStart = Offset.Unspecified
             cursorDragDelta = Offset.Unspecified
-            startContentVisibleOffset = Offset.Zero
             clearHandleDragging()
         }
 
@@ -393,10 +435,8 @@
                     // mark start drag point
                     cursorDragStart = getAdjustedCoordinates(cursorRect.bottomCenter)
                     cursorDragDelta = Offset.Zero
-                    startContentVisibleOffset = innerCoordinates
-                        ?.visibleBounds()
-                        ?.topLeft ?: Offset.Zero
                     isInTouchMode = true
+                    markStartContentVisibleOffset()
                     updateHandleDragging(Handle.Cursor, cursorDragStart)
                 },
                 onDragEnd = { onDragStop() },
@@ -404,20 +444,10 @@
                 onDrag = onDrag@{ change, dragAmount ->
                     cursorDragDelta += dragAmount
 
-                    val currentContentVisibleOffset = innerCoordinates
-                        ?.visibleBounds()
-                        ?.topLeft ?: startContentVisibleOffset
-
-                    // "start position + total delta" is not enough to understand the current pointer
-                    // position relative to text layout. We need to also account for any changes to
-                    // visible offset that's caused by auto-scrolling while dragging.
-                    val currentDragPosition = cursorDragStart + cursorDragDelta +
-                        (currentContentVisibleOffset - startContentVisibleOffset)
-
-                    updateHandleDragging(Handle.Cursor, currentDragPosition)
+                    updateHandleDragging(Handle.Cursor, cursorDragStart + cursorDragDelta)
 
                     val layoutResult = textLayoutState.layoutResult ?: return@onDrag
-                    val offset = layoutResult.getOffsetForPosition(currentDragPosition)
+                    val offset = layoutResult.getOffsetForPosition(handleDragPosition)
 
                     val newSelection = TextRange(offset)
 
@@ -440,13 +470,29 @@
     private suspend fun PointerInputScope.detectTextFieldLongPressAndAfterDrag(
         requestFocus: () -> Unit
     ) {
+        var dragPreviousOffset = -1
+        var dragBeginOffsetInText = -1
+        var dragBeginPosition: Offset = Offset.Unspecified
+        var dragTotalDistance: Offset = Offset.Zero
+
+        // offsets received by this gesture detector are in decoration box coordinates
         detectDragGesturesAfterLongPress(
             onDragStart = onDragStart@{ dragStartOffset ->
-                logDebug { "onDragStart after longPress" }
+                logDebug { "onDragStart after longPress $dragStartOffset" }
+                requestFocus()
                 // at the beginning of selection disable toolbar, re-evaluate visibility after
                 // drag gesture is finished
                 showCursorHandleToolbar = false
-                requestFocus()
+
+                updateHandleDragging(
+                    handle = Handle.SelectionEnd,
+                    position = with(textLayoutState) {
+                        dragStartOffset.relativeToInputText()
+                    }
+                )
+
+                dragBeginPosition = dragStartOffset
+                dragTotalDistance = Offset.Zero
 
                 // Long Press at the blank area, the cursor should show up at the end of the line.
                 if (!textLayoutState.isPositionOnText(dragStartOffset)) {
@@ -457,6 +503,8 @@
                         selectCharsIn(TextRange(offset))
                     }
                     showCursorHandle = true
+                    showCursorHandleToolbar = true
+                    dragPreviousOffset = offset
                 } else {
                     if (textFieldState.text.isEmpty()) return@onDragStart
                     val offset = textLayoutState.getOffsetForPosition(dragStartOffset)
@@ -477,24 +525,118 @@
                         selectCharsIn(newSelection)
                     }
                     showCursorHandle = false
+                    // For touch, set the begin offset to the adjusted selection.
+                    // When char based selection is used, we want to ensure we snap the
+                    // beginning offset to the start word boundary of the first selected word.
+                    dragBeginOffsetInText = newSelection.start
+                    dragPreviousOffset = offset
                 }
             },
             onDragEnd = {
-                showCursorHandleToolbar = true
+                clearHandleDragging()
+                dragPreviousOffset = -1
+                dragBeginOffsetInText = -1
+                dragBeginPosition = Offset.Unspecified
+                dragTotalDistance = Offset.Zero
             },
-            onDragCancel = { },
-            onDrag = onDrag@{ _, _ -> }
+            onDragCancel = {
+                clearHandleDragging()
+                dragPreviousOffset = -1
+                dragBeginOffsetInText = -1
+                dragBeginPosition = Offset.Unspecified
+                dragTotalDistance = Offset.Zero
+            },
+            onDrag = onDrag@{ _, dragAmount ->
+                // selection never started, did not consume any drag
+                if (textFieldState.text.isEmpty()) return@onDrag
+
+                dragTotalDistance += dragAmount
+
+                // "start position + total delta" is not enough to understand the current
+                // pointer position relative to text layout. We need to also account for any
+                // changes to visible offset that's caused by auto-scrolling while dragging.
+                val currentDragPosition = dragBeginPosition + dragTotalDistance
+
+                logDebug { "onDrag after longPress $currentDragPosition" }
+
+                val startOffset: Int
+                val endOffset: Int
+                val adjustment: SelectionAdjustment
+
+                if (
+                    dragBeginOffsetInText < 0 && // drag started in end padding
+                    !textLayoutState.isPositionOnText(currentDragPosition) // still in end padding
+                ) {
+                    startOffset = textLayoutState.getOffsetForPosition(dragBeginPosition)
+                    endOffset = textLayoutState.getOffsetForPosition(currentDragPosition)
+
+                    adjustment = if (startOffset == endOffset) {
+                        // start and end is in the same end padding, keep the collapsed selection
+                        SelectionAdjustment.None
+                    } else {
+                        SelectionAdjustment.Word
+                    }
+                } else {
+                    startOffset = dragBeginOffsetInText.takeIf { it >= 0 }
+                        ?: textLayoutState.getOffsetForPosition(
+                            position = dragBeginPosition,
+                            coerceInVisibleBounds = false
+                        )
+                    endOffset = textLayoutState.getOffsetForPosition(
+                        position = currentDragPosition,
+                        coerceInVisibleBounds = false
+                    )
+
+                    if (dragBeginOffsetInText < 0 && startOffset == endOffset) {
+                        // if we are selecting starting from end padding,
+                        // don't start selection until we have and un-collapsed selection.
+                        return@onDrag
+                    }
+
+                    adjustment = SelectionAdjustment.Word
+                }
+
+                val prevSelection = textFieldState.text.selectionInChars
+                var newSelection = updateSelection(
+                    textFieldCharSequence = textFieldState.text,
+                    startOffset = startOffset,
+                    endOffset = endOffset,
+                    isStartHandle = false,
+                    adjustment = adjustment,
+                    previousHandleOffset = dragPreviousOffset,
+                    allowPreviousSelectionCollapsed = false,
+                )
+
+                var actingHandle = Handle.SelectionEnd
+
+                // if new selection reverses the original selection, we can treat this drag position
+                // as start handle being dragged.
+                if (!prevSelection.reversed && newSelection.reversed) {
+                    newSelection = newSelection.reverse()
+                    actingHandle = Handle.SelectionStart
+                }
+
+                // Do not allow selection to collapse on itself while dragging. Selection can
+                // reverse but does not collapse.
+                if (prevSelection.collapsed || !newSelection.collapsed) {
+                    editWithFilter {
+                        selectCharsIn(newSelection)
+                    }
+                }
+                dragPreviousOffset = endOffset
+                updateHandleDragging(
+                    handle = actingHandle,
+                    position = currentDragPosition
+                )
+            }
         )
     }
 
     private suspend fun PointerInputScope.detectSelectionHandleDragGestures(
         isStartHandle: Boolean
     ) {
-        // keep track of how visible bounds change while moving the selection handle.
-        var startContentVisibleOffset: Offset = Offset.Zero
-
         var dragBeginPosition: Offset = Offset.Unspecified
-        var dragTotalDistance: Offset = Offset.Unspecified
+        var dragTotalDistance: Offset = Offset.Zero
         var previousDragOffset = -1
         val handle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
 
@@ -502,7 +644,6 @@
             clearHandleDragging()
             dragBeginPosition = Offset.Unspecified
             dragTotalDistance = Offset.Zero
-            startContentVisibleOffset = Offset.Zero
         }
 
         // b/288931376: detectDragGestures do not call onDragCancel when composable is disposed.
@@ -513,12 +654,10 @@
                     // the composable coordinates.
                     dragBeginPosition = getAdjustedCoordinates(getHandlePosition(isStartHandle))
 
+                    // no need to call markStartContentVisibleOffset, since it was called by the
+                    // initial down event.
                     updateHandleDragging(handle, dragBeginPosition)
 
-                    startContentVisibleOffset = innerCoordinates
-                        ?.visibleBounds()
-                        ?.topLeft ?: Offset.Zero
-
                     // Zero out the total distance that being dragged.
                     dragTotalDistance = Offset.Zero
                     previousDragOffset = if (isStartHandle) {
@@ -533,20 +672,10 @@
                     dragTotalDistance += delta
                     val layoutResult = textLayoutState.layoutResult ?: return@onDrag
 
-                    val currentContentVisibleOffset = innerCoordinates
-                        ?.visibleBounds()
-                        ?.topLeft ?: startContentVisibleOffset
-
-                    // "start position + total delta" is not enough to understand the current
-                    // pointer position relative to text layout. We need to also account for any
-                    // changes to visible offset that's caused by auto-scrolling while dragging.
-                    val currentDragPosition = dragBeginPosition + dragTotalDistance +
-                        (currentContentVisibleOffset - startContentVisibleOffset)
-
-                    updateHandleDragging(handle, currentDragPosition)
+                    updateHandleDragging(handle, dragBeginPosition + dragTotalDistance)
 
                     val startOffset = if (isStartHandle) {
-                        layoutResult.getOffsetForPosition(currentDragPosition)
+                        layoutResult.getOffsetForPosition(handleDragPosition)
                     } else {
                         textFieldState.text.selectionInChars.start
                     }
@@ -554,7 +683,7 @@
                     val endOffset = if (isStartHandle) {
                         textFieldState.text.selectionInChars.end
                     } else {
-                        layoutResult.getOffsetForPosition(currentDragPosition)
+                        layoutResult.getOffsetForPosition(handleDragPosition)
                     }
 
                     val prevSelection = textFieldState.text.selectionInChars
@@ -661,12 +790,6 @@
      */
     private fun getContentRect(): Rect {
         val text = textFieldState.text
-        // TODO(halilibo): better stale layout result check
-        // this is basically testing whether current layoutResult was created for the current
-        // text in TextFieldState. This is a temporary check that should be improved.
-        if (textLayoutState.layoutResult?.layoutInput?.text?.length != text.length) {
-            return Rect.Zero
-        }
         // accept cursor position as content rect when selection is collapsed
         // contentRect is defined in innerTextField coordinates, so it needs to be realigned to
         // root container.
@@ -761,10 +884,27 @@
     /**
      * Sets currently dragging handle state to [handle] and positions it at [position]. This is
      * mostly useful for updating the magnifier.
+     *
+     * @param handle A real or acting handle that specifies which one is being dragged.
+     * @param position Where the handle currently is
      */
-    private fun updateHandleDragging(handle: Handle, position: Offset) {
+    private fun updateHandleDragging(
+        handle: Handle,
+        position: Offset
+    ) {
         draggingHandle = handle
-        handleDragPosition = position
+        rawHandleDragPosition = position
+    }
+
+    /**
+     * When a Selection or Cursor Handle is started to being dragged, this function should be called
+     * to mark the current visible offset, so that if content gets scrolled during the drag, we
+     * can correctly offset the actual position where drag corresponds to.
+     */
+    private fun markStartContentVisibleOffset() {
+        startContentVisibleOffset = innerCoordinates
+            ?.visibleBounds()
+            ?.topLeft ?: Offset.Unspecified
     }
 
     /**
@@ -772,7 +912,8 @@
      */
     private fun clearHandleDragging() {
         draggingHandle = null
-        handleDragPosition = null
+        rawHandleDragPosition = Offset.Unspecified
+        startContentVisibleOffset = Offset.Unspecified
     }
 
     /**
@@ -979,7 +1120,9 @@
     }
 }
 
-private val DEBUG = true
+private fun TextRange.reverse() = TextRange(end, start)
+
+private val DEBUG = false
 private val DEBUG_TAG = "TextFieldSelectionState"
 
 private fun logDebug(text: () -> String) {
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.desktop.kt
similarity index 65%
rename from lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java
rename to compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.desktop.kt
index 2f5d9d9..78a357e 100644
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java
+++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text2/input/internal/TextFieldKeyEventHandler.desktop.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2017 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,15 +14,9 @@
  * limitations under the License.
  */
 
-package androidx.lifecycle.observers;
+package androidx.compose.foundation.text2.input.internal
 
-public class InterfaceImpl2 implements Interface1, Interface2 {
-    @Override
-    public void onCreate() {
-    }
-
-    @Override
-    public void onDestroy() {
-
-    }
-}
+/**
+ * Factory function to create a platform specific [TextFieldKeyEventHandler].
+ */
+internal actual fun createTextFieldKeyEventHandler() = object : TextFieldKeyEventHandler() {}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/UpdatableAnimationStateTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/UpdatableAnimationStateTest.kt
index 7c21147..433d76b 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/UpdatableAnimationStateTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/UpdatableAnimationStateTest.kt
@@ -33,7 +33,7 @@
     private val frameClock = TestFrameClock()
 
     @OptIn(ExperimentalFoundationApi::class)
-    private val state = UpdatableAnimationState(BringIntoViewScroller.DefaultScrollAnimationSpec)
+    private val state = UpdatableAnimationState(BringIntoViewSpec.DefaultScrollAnimationSpec)
 
     @Test
     fun animateToZero_doesNothing_whenValueIsZero() {
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldBufferTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldBufferTest.kt
index 8f49834..f6f09cc 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldBufferTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldBufferTest.kt
@@ -278,6 +278,155 @@
         }
     }
 
+    @Test
+    fun setTextIfChanged_updatesText_whenChanged() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("world")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("world")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 5))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenPrefixChanged() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("1ello")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("1ello")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 5))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenPrefixAdded() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("1hello")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("1hello")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 6))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenPrefixRemoved() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("ello")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("ello")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 4))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenSuffixChanged() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("hell1")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("hell1")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 5))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenSuffixAdded() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("hello1")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("hello1")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 6))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenSuffixRemoved() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("hell")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("hell")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 4))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenMiddleChanged() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("h1llo")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("h1llo")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 5))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenMiddleAdded() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("he1llo")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("he1llo")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 6))
+    }
+
+    @Test
+    fun setTextIfChanged_updatesText_whenMiddleRemoved() {
+        val text = "hello"
+        val buffer = TextFieldBuffer(TextFieldCharSequence(text))
+
+        buffer.setTextIfChanged("helo")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+        assertThat(buffer.toString()).isEqualTo("helo")
+        assertThat(buffer.changes.getOriginalRange(0)).isEqualTo(TextRange(0, 5))
+        assertThat(buffer.changes.getRange(0)).isEqualTo(TextRange(0, 4))
+    }
+
+    @Test
+    fun setTextIfChanged_doesNotUpdateTextIfEqual() {
+        val buffer = TextFieldBuffer(TextFieldCharSequence("hello"))
+
+        buffer.setTextIfChanged("hello")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(0)
+    }
+
+    @Test
+    fun setTextIfChanged_doesNotUpdateTextIfEqual_afterChange() {
+        val buffer = TextFieldBuffer(TextFieldCharSequence("hello"))
+        buffer.append(" world")
+
+        buffer.setTextIfChanged("hello world")
+
+        assertThat(buffer.changes.changeCount).isEqualTo(1)
+    }
+
     /** Tests of private testing helper code. */
     @Test
     fun testConvertTextFieldValueToAndFromString() {
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
index a2a789b..cc832fb 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/TextFieldStateTest.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.runtime.snapshots.SnapshotStateObserver
 import androidx.compose.ui.text.TextRange
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertFailsWith
@@ -28,6 +29,8 @@
 import kotlinx.coroutines.job
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -59,6 +62,111 @@
     }
 
     @Test
+    fun edit_invalidates_whenSelectionChanged() = runTestWithSnapshotsThenCancelChildren {
+        val text = "hello"
+        val state = TextFieldState(text, initialSelectionInChars = TextRange(0))
+        var invalidationCount = 0
+        val observer = SnapshotStateObserver(onChangedExecutor = { it() })
+        val observeState: () -> Unit = { state.text }
+        observer.start()
+        try {
+            observer.observeReads(
+                scope = Unit,
+                onValueChangedForScope = {
+                    invalidationCount++
+                    observeState()
+                },
+                block = observeState
+            )
+            assertThat(invalidationCount).isEqualTo(0)
+
+            // Act.
+            state.edit {
+                selectCharsIn(TextRange(0, length))
+            }
+            advanceUntilIdle()
+            runCurrent()
+
+            // Assert.
+            assertThat(invalidationCount).isEqualTo(1)
+        } finally {
+            observer.stop()
+        }
+    }
+
+    @Test
+    fun edit_invalidates_whenTextChanged() = runTestWithSnapshotsThenCancelChildren {
+        val text = "hello"
+        val state = TextFieldState(text, initialSelectionInChars = TextRange(0))
+        var invalidationCount = 0
+        val observer = SnapshotStateObserver(onChangedExecutor = { it() })
+        val observeState: () -> Unit = { state.text }
+        observer.start()
+        try {
+            observer.observeReads(
+                scope = Unit,
+                onValueChangedForScope = {
+                    invalidationCount++
+                    observeState()
+                },
+                block = observeState
+            )
+            assertThat(invalidationCount).isEqualTo(0)
+
+            // Act.
+            state.edit {
+                append("1")
+            }
+            advanceUntilIdle()
+            runCurrent()
+
+            // Assert.
+            assertThat(invalidationCount).isEqualTo(1)
+        } finally {
+            observer.stop()
+        }
+    }
+
+    @Test
+    fun edit_doesNotInvalidate_whenNoChangesMade() = runTestWithSnapshotsThenCancelChildren {
+        val text = "hello"
+        val state = TextFieldState(text, initialSelectionInChars = TextRange(0))
+        var invalidationCount = 0
+        val observer = SnapshotStateObserver(onChangedExecutor = { it() })
+        val observeState: () -> Unit = { state.text }
+        observer.start()
+        try {
+            observer.observeReads(
+                scope = Unit,
+                onValueChangedForScope = {
+                    invalidationCount++
+                    observeState()
+                },
+                block = observeState
+            )
+            assertThat(invalidationCount).isEqualTo(0)
+
+            // Act.
+            state.edit {
+                // Change the selection but restore it before returning.
+                val originalSelection = selectionInChars
+                selectCharsIn(TextRange(0, length))
+                selectCharsIn(originalSelection)
+
+                // This will be a no-op too.
+                setTextIfChanged(text)
+            }
+            advanceUntilIdle()
+            runCurrent()
+
+            // Assert.
+            assertThat(invalidationCount).isEqualTo(0)
+        } finally {
+            observer.stop()
+        }
+    }
+
+    @Test
     fun edit_replace_changesValueInPlace() {
         state.edit {
             replace(0, 0, "hello")
diff --git a/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/compose/integration/macrobenchmark/target/LazyBoxWithConstraintsActivity.kt b/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/compose/integration/macrobenchmark/target/LazyBoxWithConstraintsActivity.kt
index d4714d9..244dd6b 100644
--- a/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/compose/integration/macrobenchmark/target/LazyBoxWithConstraintsActivity.kt
+++ b/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/compose/integration/macrobenchmark/target/LazyBoxWithConstraintsActivity.kt
@@ -75,6 +75,8 @@
 
 @Composable
 private fun NonLazyRow(entry: NestedListEntry) {
+    // Need the nested subcompose layout for testing
+    @Suppress("UnusedBoxWithConstraintsScope")
     BoxWithConstraints {
         Row(
             Modifier
diff --git a/compose/integration-tests/material-catalog/src/main/java/androidx/compose/material/catalog/NavGraph.kt b/compose/integration-tests/material-catalog/src/main/java/androidx/compose/material/catalog/NavGraph.kt
index 22f5db5..d2ce9a2 100644
--- a/compose/integration-tests/material-catalog/src/main/java/androidx/compose/material/catalog/NavGraph.kt
+++ b/compose/integration-tests/material-catalog/src/main/java/androidx/compose/material/catalog/NavGraph.kt
@@ -24,14 +24,20 @@
 import androidx.compose.material.catalog.ui.specification.Specification
 import androidx.compose.material3.catalog.library.Material3CatalogApp
 import androidx.compose.material3.catalog.library.Material3Route
+import androidx.compose.material3.catalog.library.data.UserPreferencesRepository
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.composable
 import androidx.navigation.compose.rememberNavController
 
 @Composable
 fun NavGraph() {
+    val context = LocalContext.current
     val navController = rememberNavController()
+    val userPreferencesRepository = remember { UserPreferencesRepository(context) }
     NavHost(
         navController = navController,
         startDestination = SpecificationRoute
@@ -51,6 +57,12 @@
         composable(MaterialRoute) { MaterialCatalogApp() }
         composable(Material3Route) { Material3CatalogApp() }
     }
+    LaunchedEffect(Unit) {
+        // If user has pinned any M3 Catalog screen, automatically navigate to main M3 route.
+        if (userPreferencesRepository.getFavoriteRoute() != null) {
+            navController.navigate(Material3Route)
+        }
+    }
 }
 
 private const val SpecificationRoute = "specification"
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/anchoredDraggable/AnchoredDraggableStateTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/anchoredDraggable/AnchoredDraggableStateTest.kt
index d419916..659fbfe 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/anchoredDraggable/AnchoredDraggableStateTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/anchoredDraggable/AnchoredDraggableStateTest.kt
@@ -961,6 +961,51 @@
         assertThat(state.offset).isNaN()
     }
 
+    @Test
+    fun anchoredDraggable_customDrag_settleOnInvalidState_shouldRespectConfirmValueChange() =
+        runBlocking {
+            var shouldBlockValueC = false
+            val state = AnchoredDraggableState(
+                initialValue = B,
+                positionalThreshold = defaultPositionalThreshold,
+                velocityThreshold = defaultVelocityThreshold,
+                animationSpec = defaultAnimationSpec,
+                confirmValueChange = {
+                    if (shouldBlockValueC)
+                        it != C // block state value C
+                    else
+                        true
+                }
+            )
+            val anchors = DraggableAnchors {
+                A at 0f
+                B at 200f
+                C at 300f
+            }
+
+            state.updateAnchors(anchors)
+            state.anchoredDrag {
+                dragTo(300f)
+            }
+
+            // confirm we can actually go to C
+            assertThat(state.currentValue).isEqualTo(C)
+
+            // go back to B
+            state.anchoredDrag {
+                dragTo(200f)
+            }
+            assertThat(state.currentValue).isEqualTo(B)
+
+            // disallow C
+            shouldBlockValueC = true
+
+            state.anchoredDrag {
+                dragTo(300f)
+            }
+            assertThat(state.currentValue).isNotEqualTo(C)
+        }
+
     private suspend fun suspendIndefinitely() = suspendCancellableCoroutine<Unit> { }
 
     private class HandPumpTestFrameClock : MonotonicFrameClock {
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AnchoredDraggable.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AnchoredDraggable.kt
index 3ec1356..bcdba2a 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AnchoredDraggable.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/AnchoredDraggable.kt
@@ -529,7 +529,10 @@
             }
         } finally {
             val closest = anchors.closestAnchor(offset)
-            if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
+            if (closest != null &&
+                abs(offset - anchors.positionOf(closest)) <= 0.5f &&
+                confirmValueChange.invoke(closest)
+            ) {
                 currentValue = closest
             }
         }
@@ -576,7 +579,10 @@
             } finally {
                 dragTarget = null
                 val closest = anchors.closestAnchor(offset)
-                if (closest != null && abs(offset - anchors.positionOf(closest)) <= 0.5f) {
+                if (closest != null &&
+                    abs(offset - anchors.positionOf(closest)) <= 0.5f &&
+                    confirmValueChange.invoke(closest)
+                ) {
                     currentValue = closest
                 }
             }
diff --git a/compose/material3/material3/integration-tests/material3-catalog/build.gradle b/compose/material3/material3/integration-tests/material3-catalog/build.gradle
index 28da77e..0e8ccb9 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/build.gradle
+++ b/compose/material3/material3/integration-tests/material3-catalog/build.gradle
@@ -31,8 +31,10 @@
     implementation project(":compose:foundation:foundation-layout")
     implementation project(":compose:ui:ui")
     implementation project(":compose:material:material")
+    implementation project(":compose:material:material-icons-extended")
     implementation project(":compose:material3:material3")
     implementation project(":compose:material3:material3:material3-samples")
+    implementation project(":datastore:datastore-preferences")
     implementation project(":navigation:navigation-compose")
 }
 
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/NavGraph.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/NavGraph.kt
index 4ee4ac02..80c4137 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/NavGraph.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/NavGraph.kt
@@ -16,24 +16,40 @@
 
 package androidx.compose.material3.catalog.library
 
+import androidx.compose.material3.catalog.library.data.UserPreferencesRepository
+import androidx.compose.material3.catalog.library.model.Component
 import androidx.compose.material3.catalog.library.model.Components
+import androidx.compose.material3.catalog.library.model.Example
 import androidx.compose.material3.catalog.library.model.Theme
 import androidx.compose.material3.catalog.library.ui.component.Component
 import androidx.compose.material3.catalog.library.ui.example.Example
 import androidx.compose.material3.catalog.library.ui.home.Home
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.NavType
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.composable
 import androidx.navigation.compose.rememberNavController
 import androidx.navigation.navArgument
+import kotlinx.coroutines.launch
 
 @Composable
 fun NavGraph(
     theme: Theme,
     onThemeChange: (theme: Theme) -> Unit
 ) {
+    val context = LocalContext.current
     val navController = rememberNavController()
+    val coroutineScope = rememberCoroutineScope()
+    val userPreferencesRepository = remember { UserPreferencesRepository(context) }
+    var favoriteRoute by rememberSaveable { mutableStateOf<String?>(null) }
     NavHost(
         navController = navController,
         startDestination = HomeRoute
@@ -43,10 +59,13 @@
                 components = Components,
                 theme = theme,
                 onThemeChange = onThemeChange,
-                onComponentClick = { component ->
-                    val componentId = component.id
-                    val route = "$ComponentRoute/$componentId"
-                    navController.navigate(route)
+                onComponentClick = { component -> navController.navigate(component.route()) },
+                favorite = favoriteRoute == HomeRoute,
+                onFavoriteClick = {
+                    favoriteRoute = if (favoriteRoute == HomeRoute) null else HomeRoute
+                    coroutineScope.launch {
+                        userPreferencesRepository.saveFavoriteRoute(favoriteRoute)
+                    }
                 }
             )
         }
@@ -60,16 +79,20 @@
             val arguments = requireNotNull(navBackStackEntry.arguments) { "No arguments" }
             val componentId = arguments.getInt(ComponentIdArgName)
             val component = Components.first { component -> component.id == componentId }
+            val componentRoute = component.route()
             Component(
                 component = component,
                 theme = theme,
                 onThemeChange = onThemeChange,
-                onExampleClick = { example ->
-                    val exampleIndex = component.examples.indexOf(example)
-                    val route = "$ExampleRoute/$componentId/$exampleIndex"
-                    navController.navigate(route)
-                },
-                onBackClick = { navController.popBackStack() }
+                onExampleClick = { example -> navController.navigate(example.route(component)) },
+                onBackClick = { navController.popBackStack() },
+                favorite = favoriteRoute == componentRoute,
+                onFavoriteClick = {
+                    favoriteRoute = if (favoriteRoute == componentRoute) null else componentRoute
+                    coroutineScope.launch {
+                        userPreferencesRepository.saveFavoriteRoute(favoriteRoute)
+                    }
+                }
             )
         }
         composable(
@@ -86,17 +109,47 @@
             val exampleIndex = arguments.getInt(ExampleIndexArgName)
             val component = Components.first { component -> component.id == componentId }
             val example = component.examples[exampleIndex]
+            val exampleRoute = example.route(component)
             Example(
                 component = component,
                 example = example,
                 theme = theme,
                 onThemeChange = onThemeChange,
-                onBackClick = { navController.popBackStack() }
+                onBackClick = { navController.popBackStack() },
+                favorite = favoriteRoute == exampleRoute,
+                onFavoriteClick = {
+                    favoriteRoute = if (favoriteRoute == exampleRoute) null else exampleRoute
+                    coroutineScope.launch {
+                        userPreferencesRepository.saveFavoriteRoute(favoriteRoute)
+                    }
+                }
             )
         }
     }
+    LaunchedEffect(Unit) {
+        // Navigate to the favorite route on launch, if there is one saved.
+        userPreferencesRepository.getFavoriteRoute()?.let { route ->
+            favoriteRoute = route
+            if (navController.currentDestination?.route == route) {
+                // Never navigate to the current route if we're already there.
+                return@let
+            }
+            if (route.startsWith(ExampleRoute)) {
+                // Navigate to the Component screen first so it's in the back stack as expected.
+                val componentRoute =
+                    route.replace(ExampleRoute, ComponentRoute).substringBeforeLast("/")
+                navController.navigate(componentRoute)
+            }
+            navController.navigate(route)
+        }
+    }
 }
 
+private fun Component.route() = "$ComponentRoute/$id"
+
+private fun Example.route(component: Component) =
+    "$ExampleRoute/${component.id}/${component.examples.indexOf(this)}"
+
 const val Material3Route = "material3"
 private const val HomeRoute = "home"
 private const val ComponentRoute = "component"
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/data/UserPreferencesRepository.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/data/UserPreferencesRepository.kt
new file mode 100644
index 0000000..315f717
--- /dev/null
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/data/UserPreferencesRepository.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.catalog.library.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+class UserPreferencesRepository(private val context: Context) {
+    private companion object {
+        val Context.dataStore: DataStore<Preferences> by preferencesDataStore("user_preferences")
+        val FAVORITE_ROUTE = stringPreferencesKey("favorite_route")
+    }
+
+    suspend fun saveFavoriteRoute(favoriteRoute: String?) {
+        context.dataStore.edit { preferences ->
+            if (favoriteRoute == null) {
+                preferences.remove(FAVORITE_ROUTE)
+            } else {
+                preferences[FAVORITE_ROUTE] = favoriteRoute
+            }
+        }
+    }
+
+    suspend fun getFavoriteRoute(): String? = context.dataStore.data
+        .map { preferences -> preferences[FAVORITE_ROUTE] }
+        .first()
+}
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogScaffold.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogScaffold.kt
index 041b8cd..668c591 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogScaffold.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogScaffold.kt
@@ -59,6 +59,8 @@
     licensesUrl: String = LicensesUrl,
     onThemeChange: (theme: Theme) -> Unit,
     onBackClick: () -> Unit = {},
+    favorite: Boolean,
+    onFavoriteClick: () -> Unit,
     content: @Composable (PaddingValues) -> Unit
 ) {
     val coroutineScope = rememberCoroutineScope()
@@ -74,6 +76,8 @@
                 showBackNavigationIcon = showBackNavigationIcon,
                 scrollBehavior = scrollBehavior,
                 onBackClick = onBackClick,
+                favorite = favorite,
+                onFavoriteClick = onFavoriteClick,
                 onThemeClick = { openThemePicker = true },
                 onGuidelinesClick = { context.openUrl(guidelinesUrl) },
                 onDocsClick = { context.openUrl(docsUrl) },
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogTopAppBar.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogTopAppBar.kt
index b0f3343..cb9d9b3 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogTopAppBar.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/common/CatalogTopAppBar.kt
@@ -21,12 +21,16 @@
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.ArrowBack
 import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.outlined.PushPin
 import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.TopAppBarScrollBehavior
@@ -47,6 +51,8 @@
     showBackNavigationIcon: Boolean = false,
     scrollBehavior: TopAppBarScrollBehavior? = null,
     onBackClick: () -> Unit = {},
+    favorite: Boolean = false,
+    onFavoriteClick: () -> Unit = {},
     onThemeClick: () -> Unit = {},
     onGuidelinesClick: () -> Unit = {},
     onDocsClick: () -> Unit = {},
@@ -68,6 +74,17 @@
         actions = {
             Box {
                 Row {
+                    IconButton(onClick = onFavoriteClick) {
+                        Icon(
+                            imageVector =
+                                if (favorite) Icons.Filled.PushPin else Icons.Outlined.PushPin,
+                            tint = if (favorite)
+                                MaterialTheme.colorScheme.primary
+                            else
+                                LocalContentColor.current,
+                            contentDescription = null
+                        )
+                    }
                     IconButton(onClick = onThemeClick) {
                         Icon(
                             painter = painterResource(id = R.drawable.ic_palette_24dp),
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/component/Component.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/component/Component.kt
index 5a264a2..02cfdcf 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/component/Component.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/component/Component.kt
@@ -55,7 +55,9 @@
     theme: Theme,
     onThemeChange: (theme: Theme) -> Unit,
     onExampleClick: (example: Example) -> Unit,
-    onBackClick: () -> Unit
+    onBackClick: () -> Unit,
+    favorite: Boolean = false,
+    onFavoriteClick: () -> Unit,
 ) {
     val ltr = LocalLayoutDirection.current
     CatalogScaffold(
@@ -66,7 +68,9 @@
         docsUrl = component.docsUrl,
         sourceUrl = component.sourceUrl,
         onThemeChange = onThemeChange,
-        onBackClick = onBackClick
+        onBackClick = onBackClick,
+        favorite = favorite,
+        onFavoriteClick = onFavoriteClick
     ) { paddingValues ->
         LazyColumn(
             modifier = Modifier.consumeWindowInsets(paddingValues),
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/example/Example.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/example/Example.kt
index 0783874..9994ff5 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/example/Example.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/example/Example.kt
@@ -38,7 +38,9 @@
     example: Example,
     theme: Theme,
     onThemeChange: (theme: Theme) -> Unit,
-    onBackClick: () -> Unit
+    onBackClick: () -> Unit,
+    favorite: Boolean = false,
+    onFavoriteClick: () -> Unit = {},
 ) {
     CatalogScaffold(
         topBarTitle = example.name,
@@ -48,7 +50,9 @@
         docsUrl = component.docsUrl,
         sourceUrl = example.sourceUrl,
         onThemeChange = onThemeChange,
-        onBackClick = onBackClick
+        onBackClick = onBackClick,
+        favorite = favorite,
+        onFavoriteClick = onFavoriteClick
     ) { paddingValues ->
         Box(
             modifier = Modifier
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/home/Home.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/home/Home.kt
index 7c1888f..317d5d0 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/home/Home.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/ui/home/Home.kt
@@ -41,13 +41,17 @@
     components: List<Component>,
     theme: Theme,
     onThemeChange: (theme: Theme) -> Unit,
-    onComponentClick: (component: Component) -> Unit
+    onComponentClick: (component: Component) -> Unit,
+    favorite: Boolean = false,
+    onFavoriteClick: () -> Unit,
 ) {
     val ltr = LocalLayoutDirection.current
     CatalogScaffold(
         topBarTitle = stringResource(id = R.string.compose_material_3),
         theme = theme,
-        onThemeChange = onThemeChange
+        onThemeChange = onThemeChange,
+        favorite = favorite,
+        onFavoriteClick = onFavoriteClick
     ) { paddingValues ->
         LazyVerticalGrid(
             modifier = Modifier.consumeWindowInsets(paddingValues),
diff --git a/compose/runtime/runtime/build.gradle b/compose/runtime/runtime/build.gradle
index e5aab05..8a8b37c 100644
--- a/compose/runtime/runtime/build.gradle
+++ b/compose/runtime/runtime/build.gradle
@@ -48,7 +48,24 @@
             }
         }
 
+        nonEmulatorCommonMain {
+            dependsOn(commonMain)
+        }
+
+        nonEmulatorCommonTest {
+            dependsOn(commonTest)
+        }
+
+        nonEmulatorJvmMain {
+            dependsOn(nonEmulatorCommonMain)
+        }
+
+        nonEmulatorJvmTest {
+            dependsOn(nonEmulatorCommonTest)
+        }
+
         jvmMain {
+            dependsOn(nonEmulatorJvmMain)
             dependencies {
                 implementation(libs.kotlinStdlib)
                 api(libs.kotlinCoroutinesCore)
@@ -76,18 +93,6 @@
             }
         }
 
-        nonEmulatorCommonTest {
-            dependsOn(commonTest)
-            dependencies {
-            }
-        }
-
-        nonEmulatorJvmTest {
-            dependsOn(nonEmulatorCommonTest)
-            dependencies {
-            }
-        }
-
         androidAndroidTest {
             dependsOn(jvmTest)
             dependencies {
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
index 62ccc73..219446f 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/ComposeBenchmarkBase.kt
@@ -18,11 +18,20 @@
 
 import android.view.View
 import androidx.activity.compose.setContent
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.MetricCapture
+import androidx.benchmark.MicrobenchmarkConfig
+import androidx.benchmark.TimeCapture
 import androidx.benchmark.junit4.BenchmarkRule
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.ControlledComposition
 import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.currentComposer
 import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.runtime.tooling.CompositionData
+import androidx.compose.runtime.tooling.LocalInspectionTables
 import androidx.compose.ui.test.ExperimentalTestApi
 import androidx.compose.ui.test.TestMonotonicFrameClock
 import kotlin.coroutines.CoroutineContext
@@ -38,9 +47,71 @@
 import org.junit.Assert.assertTrue
 import org.junit.Rule
 
+private const val GROUP_METRIC_NAME = "Groups"
+private const val GROUP_METRIC_INDEX = 0
+private const val SLOT_METRIC_NAME = "Slots"
+private const val SLOT_METRIC_INDEX = 1
+
+private var compositionTables: MutableSet<CompositionData>? = null
+private var groupsCount: Long = 0
+private var slotsCount: Long = 0
+
+private fun countGroupsAndSlots(table: CompositionData, tables: Set<CompositionData>) {
+    for (group in table.compositionGroups) {
+        groupsCount += group.groupSize
+        slotsCount += group.slotsSize
+    }
+
+    for (subTable in tables) {
+        for (group in subTable.compositionGroups) {
+            groupsCount += group.groupSize
+            slotsCount += group.slotsSize
+        }
+    }
+}
+
+@Composable
+private fun CountGroupsAndSlots(content: @Composable () -> Unit) {
+    val data = currentComposer.compositionData
+    CompositionLocalProvider(LocalInspectionTables provides compositionTables, content = content)
+    SideEffect {
+        compositionTables?.let {
+            countGroupsAndSlots(data, it)
+        }
+    }
+}
+
+@OptIn(ExperimentalBenchmarkConfigApi::class)
 abstract class ComposeBenchmarkBase {
     @get:Rule
-    val benchmarkRule = BenchmarkRule()
+    val benchmarkRule = BenchmarkRule(
+        MicrobenchmarkConfig(
+            metrics = listOf(
+                TimeCapture(),
+                object : MetricCapture(listOf(GROUP_METRIC_NAME, SLOT_METRIC_NAME)) {
+                    override fun captureStart(timeNs: Long) {
+                        compositionTables = mutableSetOf()
+                        groupsCount = 0
+                        slotsCount = 0
+                    }
+
+                    override fun captureStop(timeNs: Long, output: LongArray, offset: Int) {
+                        output[offset + GROUP_METRIC_INDEX] = groupsCount
+                        output[offset + SLOT_METRIC_INDEX] = slotsCount
+                        compositionTables = null
+                    }
+
+                    override fun capturePaused() {
+                        // Unsupported for now
+                    }
+
+                    override fun captureResumed() {
+                        // Unsupported for now
+                    }
+                }
+            ),
+        )
+    )
 
     @Suppress("DEPRECATION")
     @get:Rule
@@ -60,7 +131,7 @@
         try {
             benchmarkRule.measureRepeatedSuspendable {
                 activity.setContent(recomposer) {
-                    block()
+                    CountGroupsAndSlots(block)
                 }
 
                 runWithTimingDisabled {
@@ -91,7 +162,7 @@
         launch { recomposer.runRecomposeAndApplyChanges() }
 
         activity.setContent(recomposer) {
-            receiver.composeCb()
+            CountGroupsAndSlots(receiver.composeCb)
         }
 
         var iterations = 0
diff --git a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateAutoboxingBenchmark.kt b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateAutoboxingBenchmark.kt
index 1889856..dcac97a 100644
--- a/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateAutoboxingBenchmark.kt
+++ b/compose/runtime/runtime/compose-runtime-benchmark/src/androidTest/java/androidx/compose/runtime/benchmark/SnapshotStateAutoboxingBenchmark.kt
@@ -24,6 +24,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.OrderWith
 import org.junit.runner.RunWith
@@ -69,11 +70,13 @@
         benchmarkPrimitiveWrites(FramesInTenSecondHighRefreshAnimation)
     }
 
+    @Ignore("b/294259234")
     @Test
     fun benchmarkManyBoxedFloatValueWrites() {
         benchmarkBoxedWrites(ManyWritesCount)
     }
 
+    @Ignore("b/294259234")
     @Test
     fun benchmarkManyPrimitiveFloatValueWrites() {
         benchmarkPrimitiveWrites(ManyWritesCount)
diff --git a/compose/ui/ui-graphics/benchmark/test/src/androidTest/java/androidx/compose/ui/graphics/benchmark/test/ImageVectorTest.kt b/compose/ui/ui-graphics/benchmark/test/src/androidTest/java/androidx/compose/ui/graphics/benchmark/test/ImageVectorTest.kt
index 6292556..d6b4593 100644
--- a/compose/ui/ui-graphics/benchmark/test/src/androidTest/java/androidx/compose/ui/graphics/benchmark/test/ImageVectorTest.kt
+++ b/compose/ui/ui-graphics/benchmark/test/src/androidTest/java/androidx/compose/ui/graphics/benchmark/test/ImageVectorTest.kt
@@ -19,8 +19,10 @@
 import android.os.Build
 import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.asAndroidBitmap
@@ -62,14 +64,23 @@
         val xmlTestCase = XmlVectorTestCase()
         val programmaticTestCase = ProgrammaticVectorTestCase()
 
+        var firstRun by mutableStateOf(true)
         rule.setContent {
-            Column {
-                xmlTestCase.Content()
-                programmaticTestCase.Content()
+            Box {
+                if (firstRun) {
+                    xmlTestCase.Content()
+                } else {
+                    programmaticTestCase.Content()
+                }
             }
         }
 
         val xmlBitmap = rule.onNodeWithTag(xmlTestCase.testTag).captureToImage().asAndroidBitmap()
+
+        firstRun = false
+
+        rule.waitForIdle()
+
         val programmaticBitmap = rule.onNodeWithTag(programmaticTestCase.testTag).captureToImage()
             .asAndroidBitmap()
 
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/accessibility/CollectionInfoTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/accessibility/CollectionInfoTest.kt
index 181ef0b..57fcf69 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/accessibility/CollectionInfoTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/accessibility/CollectionInfoTest.kt
@@ -17,11 +17,15 @@
 package androidx.compose.ui.accessibility
 
 import android.view.ViewGroup
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.selection.selectable
 import androidx.compose.foundation.selection.selectableGroup
 import androidx.compose.foundation.text.BasicText
@@ -367,6 +371,30 @@
         }
     }
 
+    @OptIn(ExperimentalFoundationApi::class)
+    @Test
+    fun testCollectionInfo_Pager() {
+        val pageCount = 20
+        val horizontalPagerTag = "horizontalPager"
+        val verticalPagerTag = "verticalPager"
+
+        setContent {
+            val state = rememberPagerState { pageCount }
+            HorizontalPager(state = state, Modifier.testTag(horizontalPagerTag)) {}
+            VerticalPager(state = state, Modifier.testTag(verticalPagerTag)) {}
+        }
+
+        var pager = rule.onNodeWithTag(horizontalPagerTag).fetchSemanticsNode()
+        populateAccessibilityNodeInfoProperties(pager)
+
+        Assert.assertEquals(info.collectionInfo.columnCount, pageCount)
+
+        pager = rule.onNodeWithTag(verticalPagerTag).fetchSemanticsNode()
+        populateAccessibilityNodeInfoProperties(pager)
+
+        Assert.assertEquals(info.collectionInfo.rowCount, pageCount)
+    }
+
     private fun setContent(content: @Composable () -> Unit) {
         rule.runOnIdle {
             container.setContent(content)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
index 1098436..9efacc9 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/PainterModifierTest.kt
@@ -743,12 +743,12 @@
             assertEquals(Color.Gray.toArgb(), getPixel(boxWidth / 2 - vectorWidth - 5, 0))
             assertEquals(
                 Color.Gray.toArgb(),
-                getPixel(boxWidth / 2 + vectorWidth + 5, boxHeight - 1)
+                getPixel(boxWidth / 2 + vectorWidth + 5, boxHeight - 2)
             )
             assertEquals(Color.Red.toArgb(), getPixel(boxWidth / 2 - vectorWidth + 5, 0))
             assertEquals(
                 Color.Red.toArgb(),
-                getPixel(boxWidth / 2 + vectorWidth - 5, boxHeight - 1)
+                getPixel(boxWidth / 2 + vectorWidth - 5, boxHeight - 2)
             )
         }
     }
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/graphics/vector/VectorTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/graphics/vector/VectorTest.kt
index ab1ddee..a3cf87d 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/graphics/vector/VectorTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/graphics/vector/VectorTest.kt
@@ -34,9 +34,11 @@
 import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.testutils.assertPixels
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.AtLeastSize
@@ -45,10 +47,12 @@
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.draw.paint
 import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Canvas
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.ImageBitmapConfig
 import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.graphics.asAndroidBitmap
 import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
@@ -123,8 +127,9 @@
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
     @Test
     fun testVectorIntrinsicTintFirstFrame() {
+        var vector: VectorPainter? = null
         rule.setContent {
-            val vector = createTestVectorPainter(200, Color.Magenta)
+            vector = createTestVectorPainter(200, Color.Magenta)
 
             val bitmap = remember {
                 val bitmap = ImageBitmap(200, 200)
@@ -136,7 +141,7 @@
                     canvas,
                     bitmapSize
                 ) {
-                    with(vector) {
+                    with(vector!!) {
                         draw(bitmapSize)
                     }
                 }
@@ -151,6 +156,7 @@
         takeScreenShot(200).apply {
             assertEquals(getPixel(100, 100), Color.Magenta.toArgb())
         }
+        assertEquals(ImageBitmapConfig.Alpha8, vector!!.bitmapConfig!!)
     }
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@@ -374,11 +380,11 @@
             captureToImage().asAndroidBitmap().apply {
                 assertEquals(Color.Red.toArgb(), getPixel(width - 2, 0))
                 assertEquals(Color.Red.toArgb(), getPixel(2, 0))
-                assertEquals(Color.Red.toArgb(), getPixel(width - 1, height - 2))
+                assertEquals(Color.Red.toArgb(), getPixel(width - 1, height - 4))
 
                 assertEquals(Color.Black.toArgb(), getPixel(0, 2))
-                assertEquals(Color.Black.toArgb(), getPixel(0, height - 1))
-                assertEquals(Color.Black.toArgb(), getPixel(width - 2, height - 1))
+                assertEquals(Color.Black.toArgb(), getPixel(0, height - 2))
+                assertEquals(Color.Black.toArgb(), getPixel(width - 4, height - 2))
             }
             performClick()
         }
@@ -388,22 +394,111 @@
         rule.onNodeWithTag(testTag).captureToImage().asAndroidBitmap().apply {
             assertEquals(Color.Black.toArgb(), getPixel(width - 2, 0))
             assertEquals(Color.Black.toArgb(), getPixel(2, 0))
-            assertEquals(Color.Black.toArgb(), getPixel(width - 1, height - 2))
+            assertEquals(Color.Black.toArgb(), getPixel(width - 1, height - 4))
 
             assertEquals(Color.Red.toArgb(), getPixel(0, 2))
             assertEquals(Color.Red.toArgb(), getPixel(0, height - 2))
-            assertEquals(Color.Red.toArgb(), getPixel(width - 2, height - 1))
+            assertEquals(Color.Red.toArgb(), getPixel(width - 4, height - 2))
         }
     }
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
     @Test
+    fun testPathColorChangeUpdatesBitmapConfig() {
+        val defaultWidth = 24.dp
+        val defaultHeight = 24.dp
+        val testTag = "testTag"
+        var vectorPainter: VectorPainter? = null
+        var brush: Brush by mutableStateOf(SolidColor(Color.Blue))
+        rule.setContent {
+            vectorPainter = rememberVectorPainter(
+                defaultWidth = defaultWidth,
+                defaultHeight = defaultHeight,
+                autoMirror = false
+            ) { viewportWidth, viewportHeight ->
+                Path(
+                    fill = brush,
+                    pathData = PathData {
+                        lineTo(viewportWidth, 0f)
+                        lineTo(viewportWidth, viewportHeight)
+                        lineTo(0f, viewportHeight)
+                        close()
+                    }
+                )
+            }
+            Image(
+                painter = vectorPainter!!,
+                contentDescription = null,
+                modifier = Modifier
+                    .testTag(testTag)
+                    .size(defaultWidth * 8, defaultHeight * 2)
+                    .background(Color.Red),
+                contentScale = ContentScale.FillBounds
+            )
+        }
+
+        rule.onNodeWithTag(testTag).captureToImage().assertPixels { Color.Blue }
+        assertEquals(ImageBitmapConfig.Alpha8, vectorPainter!!.bitmapConfig!!)
+
+        brush = Brush.horizontalGradient(listOf(Color.Blue, Color.Blue))
+        rule.onNodeWithTag(testTag).captureToImage().assertPixels { Color.Blue }
+        assertEquals(ImageBitmapConfig.Argb8888, vectorPainter!!.bitmapConfig!!)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun testGroupPathColorChangeUpdatesBitmapConfig() {
+        val defaultWidth = 24.dp
+        val defaultHeight = 24.dp
+        val testTag = "testTag"
+        var vectorPainter: VectorPainter? = null
+        var brush: Brush by mutableStateOf(SolidColor(Color.Blue))
+        rule.setContent {
+            vectorPainter = rememberVectorPainter(
+                defaultWidth = defaultWidth,
+                defaultHeight = defaultHeight,
+                autoMirror = false
+            ) { viewportWidth, viewportHeight ->
+                Group {
+                    Path(
+                        fill = brush,
+                        pathData = PathData {
+                            lineTo(viewportWidth, 0f)
+                            lineTo(viewportWidth, viewportHeight)
+                            lineTo(0f, viewportHeight)
+                            close()
+                        }
+                    )
+                }
+            }
+            Image(
+                painter = vectorPainter!!,
+                contentDescription = null,
+                modifier = Modifier
+                    .testTag(testTag)
+                    .size(defaultWidth * 8, defaultHeight * 2)
+                    .background(Color.Red),
+                contentScale = ContentScale.FillBounds
+            )
+        }
+
+        rule.onNodeWithTag(testTag).captureToImage().assertPixels { Color.Blue }
+        assertEquals(ImageBitmapConfig.Alpha8, vectorPainter!!.bitmapConfig!!)
+
+        brush = Brush.horizontalGradient(listOf(Color.Blue, Color.Blue))
+        rule.onNodeWithTag(testTag).captureToImage().assertPixels { Color.Blue }
+        assertEquals(ImageBitmapConfig.Argb8888, vectorPainter!!.bitmapConfig!!)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
     fun testVectorScaleNonUniformly() {
         val defaultWidth = 24.dp
         val defaultHeight = 24.dp
         val testTag = "testTag"
+        var vectorPainter: VectorPainter? = null
         rule.setContent {
-            val vectorPainter = rememberVectorPainter(
+            vectorPainter = rememberVectorPainter(
                 defaultWidth = defaultWidth,
                 defaultHeight = defaultHeight,
                 autoMirror = false
@@ -419,17 +514,18 @@
                 )
             }
             Image(
-                painter = vectorPainter,
+                painter = vectorPainter!!,
                 contentDescription = null,
                 modifier = Modifier
                     .testTag(testTag)
-                    .size(defaultWidth * 7, defaultHeight * 3)
+                    .size(defaultWidth * 8, defaultHeight * 2)
                     .background(Color.Red),
                 contentScale = ContentScale.FillBounds
             )
         }
 
         rule.onNodeWithTag(testTag).captureToImage().assertPixels { Color.Blue }
+        assertEquals(ImageBitmapConfig.Alpha8, vectorPainter!!.bitmapConfig!!)
     }
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacedChildTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacedChildTest.kt
index cb754c6..2695a58 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacedChildTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/PlacedChildTest.kt
@@ -210,6 +210,56 @@
             assertThat(placementCount).isEqualTo(0)
         }
     }
+
+    @Test
+    fun placementIsNotCalledOnChildOfNotPlacedParent() {
+        val counterState = mutableStateOf(0)
+        val shouldPlaceState = mutableStateOf(true)
+        var placementCount = 0
+        rule.setContent {
+            Layout(content = {
+                Layout(content = {
+                    Layout { _, _ ->
+                        counterState.value
+                        layout(50, 50) {
+                            placementCount++
+                        }
+                    }
+                }) { measurables, constraints ->
+                    // this parent is always placing a child
+                    val placeable = measurables.first().measure(constraints)
+                    layout(placeable.width, placeable.height) {
+                        placeable.place(0, 0)
+                    }
+                }
+            }) { measurables, constraints ->
+                val placeable = measurables.first().measure(constraints)
+                val shouldPlace = shouldPlaceState.value
+                layout(placeable.width, placeable.height) {
+                    // this parent is placing a child conditionally
+                    if (shouldPlace) {
+                        placeable.place(0, 0)
+                    }
+                }
+            }
+        }
+
+        rule.runOnIdle {
+            placementCount = 0
+            // we trigger remeasurement for the leaf node
+            counterState.value++
+
+            // and we also trigger remeasurement for the grand-parent
+            // during this remeasurement forceMeasureTheSubtree will reach the leaf node
+            // via the forceMeasureTheSubtree, however it shouldn't run its placement block
+            // as the leaf node will not be placed in the end
+            shouldPlaceState.value = false
+        }
+
+        rule.runOnIdle {
+            assertThat(placementCount).isEqualTo(0)
+        }
+    }
 }
 
 private val UseChildSizeButNotPlace = object : LayoutNode.NoIntrinsicsMeasurePolicy("") {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/DrawCache.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/DrawCache.kt
index 1c4a8e1..84d103d 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/DrawCache.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/DrawCache.kt
@@ -21,6 +21,7 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.ImageBitmapConfig
 import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.unit.Density
@@ -43,6 +44,7 @@
     private var scopeDensity: Density? = null
     private var layoutDirection: LayoutDirection = LayoutDirection.Ltr
     private var size: IntSize = IntSize.Zero
+    private var config: ImageBitmapConfig? = null
 
     private val cacheScope = CanvasDrawScope()
 
@@ -52,6 +54,7 @@
      * re-used and the contents are cleared out before drawing content in it again
      */
     fun drawCachedImage(
+        config: ImageBitmapConfig,
         size: IntSize,
         density: Density,
         layoutDirection: LayoutDirection,
@@ -64,13 +67,15 @@
         if (targetImage == null ||
             targetCanvas == null ||
             size.width > targetImage.width ||
-            size.height > targetImage.height
+            size.height > targetImage.height ||
+            this.config != config
         ) {
-            targetImage = ImageBitmap(size.width, size.height)
+            targetImage = ImageBitmap(size.width, size.height, config = config)
             targetCanvas = Canvas(targetImage)
 
             mCachedImage = targetImage
             cachedCanvas = targetCanvas
+            this.config = config
         }
         this.size = size
         cacheScope.draw(density, layoutDirection, targetCanvas, size.toSize()) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/Vector.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/Vector.kt
index 4690c99..dbe545c 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/Vector.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/Vector.kt
@@ -26,16 +26,20 @@
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ImageBitmapConfig
 import androidx.compose.ui.graphics.Matrix
 import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.PathFillType
 import androidx.compose.ui.graphics.PathMeasure
+import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.graphics.StrokeCap
 import androidx.compose.ui.graphics.StrokeJoin
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.scale
 import androidx.compose.ui.graphics.drawscope.withTransform
+import androidx.compose.ui.graphics.isSpecified
+import androidx.compose.ui.graphics.isUnspecified
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.util.fastForEach
 import kotlin.math.ceil
@@ -80,10 +84,10 @@
      * Callback invoked whenever the node in the vector tree is modified in a way that would
      * change the output of the Vector
      */
-    internal open var invalidateListener: (() -> Unit)? = null
+    internal open var invalidateListener: ((VNode) -> Unit)? = null
 
     fun invalidate() {
-        invalidateListener?.invoke()
+        invalidateListener?.invoke(this)
     }
 
     abstract fun DrawScope.draw()
@@ -107,10 +111,17 @@
 
     private val cacheDrawScope = DrawCache()
 
+    internal val cacheBitmapConfig: ImageBitmapConfig?
+        get() = cacheDrawScope.mCachedImage?.config
+
     internal var invalidateCallback = {}
 
     internal var intrinsicColorFilter: ColorFilter? by mutableStateOf(null)
 
+    // Conditional filter used if the vector is all one color. In this case we allocate a
+    // alpha8 channel bitmap and tint the result to the desired color
+    private var tintFilter: ColorFilter? = null
+
     internal var viewportSize by mutableStateOf(Size.Zero)
 
     private var previousDrawSize = Unspecified
@@ -130,13 +141,23 @@
     }
 
     fun DrawScope.draw(alpha: Float, colorFilter: ColorFilter?) {
-        val targetColorFilter = colorFilter ?: intrinsicColorFilter
         // If the content of the vector has changed, or we are drawing a different size
         // update the cached image to ensure we are scaling the vector appropriately
+        val isOneColor = root.isTintable && root.tintColor.isSpecified
         if (isDirty || previousDrawSize != size) {
+            if (colorFilter == null && intrinsicColorFilter == null && isOneColor) {
+                tintFilter = ColorFilter.tint(root.tintColor)
+            } else if (!isOneColor) {
+                tintFilter = null
+            }
             rootScaleX = size.width / viewportSize.width
             rootScaleY = size.height / viewportSize.height
             cacheDrawScope.drawCachedImage(
+                if (isOneColor) {
+                    ImageBitmapConfig.Alpha8
+                } else {
+                    ImageBitmapConfig.Argb8888
+                },
                 IntSize(ceil(size.width).toInt(), ceil(size.height).toInt()),
                 this@draw,
                 layoutDirection,
@@ -145,7 +166,14 @@
             isDirty = false
             previousDrawSize = size
         }
-        cacheDrawScope.drawInto(this, alpha, targetColorFilter)
+        val targetFilter = if (colorFilter != null) {
+            colorFilter
+        } else if (intrinsicColorFilter != null) {
+            intrinsicColorFilter
+        } else {
+            tintFilter
+        }
+        cacheDrawScope.drawInto(this, alpha, targetFilter)
     }
 
     override fun DrawScope.draw() {
@@ -329,6 +357,82 @@
 
     private val children = mutableListOf<VNode>()
 
+    /**
+     * Flag to determine if the contents of this group can be rendered with a single color
+     * This is true if all the paths and groups within this group can be rendered with the
+     * same color
+     */
+    var isTintable = true
+        private set
+
+    /**
+     * Tint color to render all the contents of this group. This is configured only if all the
+     * contents within the group are the same color
+     */
+    var tintColor = Color.Unspecified
+        private set
+
+    /**
+     * Helper method to inspect whether the provided brush matches the current color of paths
+     * within the group in order to help determine if only an alpha channel bitmap can be allocated
+     * and tinted in order to save on memory overhead.
+     */
+    private fun markTintForBrush(brush: Brush?) {
+        if (!isTintable) {
+            return
+        }
+        if (brush != null) {
+            if (brush is SolidColor) {
+                markTintForColor(brush.value)
+            } else {
+                // If the brush is not a solid color then we require a explicit ARGB channels in the
+                // cached bitmap
+                markNotTintable()
+            }
+        }
+    }
+
+    /**
+     * Helper method to inspect whether the provided color matches the current color of paths
+     * within the group in order to help determine if only an alpha channel bitmap can be allocated
+     * and tinted in order to save on memory overhead.
+     */
+    private fun markTintForColor(color: Color) {
+        if (!isTintable) {
+            return
+        }
+
+        if (color.isSpecified) {
+            if (tintColor.isUnspecified) {
+                // Initial color has not been specified, initialize the target color to the
+                // one provided
+                tintColor = color
+            } else if (!tintColor.rgbEqual(color)) {
+                // The given color does not match the rgb channels if our previous color
+                // Therefore we require explicit ARGB channels in the cached bitmap
+                markNotTintable()
+            }
+        }
+    }
+
+    private fun markTintForVNode(node: VNode) {
+        if (node is PathComponent) {
+            markTintForBrush(node.fill)
+            markTintForBrush(node.stroke)
+        } else if (node is GroupComponent) {
+            if (node.isTintable && isTintable) {
+                markTintForColor(node.tintColor)
+            } else {
+                markNotTintable()
+            }
+        }
+    }
+
+    private fun markNotTintable() {
+        isTintable = false
+        tintColor = Color.Unspecified
+    }
+
     var clipPathData = EmptyPath
         set(value) {
             field = value
@@ -343,13 +447,12 @@
 
     private var clipPath: Path? = null
 
-    override var invalidateListener: (() -> Unit)? = null
-        set(value) {
-            field = value
-            children.fastForEach { child ->
-                child.invalidateListener = value
-            }
-        }
+    override var invalidateListener: ((VNode) -> Unit)? = null
+
+    private val wrappedListener: (VNode) -> Unit = { node ->
+        markTintForVNode(node)
+        invalidateListener?.invoke(node)
+    }
 
     private fun updateClipPath() {
         if (willClipPath) {
@@ -451,7 +554,10 @@
         } else {
             children.add(instance)
         }
-        instance.invalidateListener = invalidateListener
+
+        markTintForVNode(instance)
+
+        instance.invalidateListener = wrappedListener
         invalidate()
     }
 
@@ -518,3 +624,12 @@
         return sb.toString()
     }
 }
+
+/**
+ * helper method to verify if the rgb channels are equal excluding comparison of the alpha
+ * channel
+ */
+internal fun Color.rgbEqual(other: Color) =
+    this.red == other.red &&
+        this.green == other.green &&
+        this.blue == other.blue
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorCompose.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorCompose.kt
index 381d62c..51eac4b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorCompose.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorCompose.kt
@@ -136,11 +136,11 @@
 
 class VectorApplier(root: VNode) : AbstractApplier<VNode>(root) {
     override fun insertTopDown(index: Int, instance: VNode) {
-        current.asGroup().insertAt(index, instance)
+        // Ignored as the tree is built bottom-up.
     }
 
     override fun insertBottomUp(index: Int, instance: VNode) {
-        // Ignored as the tree is built top-down.
+        current.asGroup().insertAt(index, instance)
     }
 
     override fun remove(index: Int, count: Int) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorPainter.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorPainter.kt
index 0fe62d2..4ad4325 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorPainter.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/vector/VectorPainter.kt
@@ -30,6 +30,7 @@
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ImageBitmapConfig
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.scale
 import androidx.compose.ui.graphics.isSpecified
@@ -179,15 +180,6 @@
     )
 
 /**
- * Functional interface to avoid "PrimitiveInLambda" lint errors
- */
-internal fun interface ComposeVector {
-
-    @Composable
-    fun Content(viewportWidth: Float, viewportHeight: Float)
-}
-
-/**
  * [Painter] implementation that abstracts the drawing of a Vector graphic.
  * This can be represented by either a [ImageVector] or a programmatic
  * composition of a vector
@@ -227,6 +219,9 @@
         }
     }
 
+    internal val bitmapConfig: ImageBitmapConfig?
+        get() = vector.cacheBitmapConfig
+
     internal var composition: Composition? = null
 
     // TODO replace with mutableStateOf(Unit, neverEqualPolicy()) after b/291647821 is addressed
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt
index 9c53aa8..d9b36df 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/MeasureAndLayoutDelegate.kt
@@ -448,7 +448,8 @@
      */
     private fun remeasureAndRelayoutIfNeeded(
         layoutNode: LayoutNode,
-        affectsLookahead: Boolean = true
+        affectsLookahead: Boolean = true,
+        relayoutNeeded: Boolean = true
     ): Boolean {
         var sizeChanged = false
         if (layoutNode.isPlaced ||
@@ -470,14 +471,18 @@
             ) {
                 layoutNode.lookaheadReplace()
             }
-            if (layoutNode.layoutPending && (layoutNode.isPlacedByParent || layoutNode === root)) {
-                if (layoutNode === root) {
-                    layoutNode.place(0, 0)
-                } else {
-                    layoutNode.replace()
+            if (layoutNode.layoutPending && relayoutNeeded) {
+                val isPlacedByPlacedParent = layoutNode === root ||
+                    (layoutNode.parent?.isPlaced == true && layoutNode.isPlacedByParent)
+                if (isPlacedByPlacedParent) {
+                    if (layoutNode === root) {
+                        layoutNode.place(0, 0)
+                    } else {
+                        layoutNode.replace()
+                    }
+                    onPositionedDispatcher.onNodePositioned(layoutNode)
+                    consistencyChecker?.assertConsistent()
                 }
-                onPositionedDispatcher.onNodePositioned(layoutNode)
-                consistencyChecker?.assertConsistent()
             }
             // execute postponed `onRequestMeasure`
             if (postponedMeasureRequests.isNotEmpty()) {
@@ -539,11 +544,12 @@
         require(!pending(layoutNode)) { "node not yet measured" }
 
         layoutNode.forEachChild { child ->
-            if (pending(child) && relayoutNodes.remove(child, affectsLookahead)) {
-                // If lookaheadMeasurePending && this forceMeasureSubtree call doesn't affect
-                // lookahead, we'll leave the node in the [relayoutNodes] for further lookahead
-                // remeasurement.
-                remeasureAndRelayoutIfNeeded(child, affectsLookahead)
+            if (pending(child) && relayoutNodes.contains(child, affectsLookahead)) {
+                // we don't need to run relayout as part of this logic. so the node will
+                // not be removed from `relayoutNodes` in order to be visited again during
+                // the regular pass. it is important as the parent of this node can decide
+                // to not place this child, so the child relayout should be skipped.
+                remeasureAndRelayoutIfNeeded(child, affectsLookahead, relayoutNeeded = false)
             }
 
             // if the child is still in NeedsRemeasure state then this child remeasure wasn't
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NestedVectorStack.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NestedVectorStack.kt
index 915bc00..dd0f8de 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NestedVectorStack.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NestedVectorStack.kt
@@ -17,41 +17,44 @@
 package androidx.compose.ui.node
 
 import androidx.compose.runtime.collection.MutableVector
-import androidx.compose.runtime.collection.mutableVectorOf
 
 internal class NestedVectorStack<T> {
-    private var current: Int = -1
-    private var lastIndex = 0
-    private var indexes = IntArray(16)
-    private val vectors = mutableVectorOf<MutableVector<T>>()
-    private fun pushIndex(value: Int) {
-        if (lastIndex >= indexes.size) {
-            indexes = indexes.copyOf(indexes.size * 2)
-        }
-        indexes[lastIndex++] = value
-    }
+    // number of vectors in the stack
+    private var size = 0
+    // holds the current "top" index for each vector
+    private var currentIndexes = IntArray(16)
+    private var vectors = arrayOfNulls<MutableVector<T>>(16)
 
     fun isNotEmpty(): Boolean {
-        return current >= 0 && indexes[current] >= 0
+        return size > 0 && currentIndexes[size - 1] >= 0
     }
 
     fun pop(): T {
-        val i = current
-        val index = indexes[i]
-        val vector = vectors[i]
-        if (index > 0) indexes[i]--
-        else if (index == 0) {
-            vectors.removeAt(i)
-            current--
+        check(size > 0) {
+            "Cannot call pop() on an empty stack. Guard with a call to isNotEmpty()"
         }
-        return vector[index]
+        val indexOfVector = size - 1
+        val indexOfItem = currentIndexes[indexOfVector]
+        val vector = vectors[indexOfVector]!!
+        if (indexOfItem > 0) currentIndexes[indexOfVector]--
+        else if (indexOfItem == 0) {
+            vectors[indexOfVector] = null
+            size--
+        }
+        return vector[indexOfItem]
     }
 
     fun push(vector: MutableVector<T>) {
-        if (vector.isNotEmpty()) {
-            vectors.add(vector)
-            pushIndex(vector.size - 1)
-            current++
+        // if the vector is empty there is no reason for us to add it
+        if (vector.isEmpty()) return
+        val nextIndex = size
+        // check to see that we have capacity to add another vector
+        if (nextIndex >= currentIndexes.size) {
+            currentIndexes = currentIndexes.copyOf(currentIndexes.size * 2)
+            vectors = vectors.copyOf(vectors.size * 2)
         }
+        currentIndexes[nextIndex] = vector.size - 1
+        vectors[nextIndex] = vector
+        size++
     }
 }
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/res/DesktopSvgResources.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/res/DesktopSvgResources.desktop.kt
index 677bb91..067c0d7 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/res/DesktopSvgResources.desktop.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/res/DesktopSvgResources.desktop.kt
@@ -20,6 +20,7 @@
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.geometry.isSpecified
 import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ImageBitmapConfig
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
 import androidx.compose.ui.graphics.nativeCanvas
@@ -106,6 +107,7 @@
     override fun DrawScope.onDraw() {
         if (previousDrawSize != size) {
             drawCache.drawCachedImage(
+                ImageBitmapConfig.Argb8888,
                 IntSize(ceil(size.width).toInt(), ceil(size.height).toInt()),
                 density = this,
                 layoutDirection,
diff --git a/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/NestedVectorStackTest.kt b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/NestedVectorStackTest.kt
new file mode 100644
index 0000000..3b08674
--- /dev/null
+++ b/compose/ui/ui/src/test/kotlin/androidx/compose/ui/node/NestedVectorStackTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.compose.ui.node
+
+import androidx.compose.runtime.collection.mutableVectorOf
+import com.google.common.truth.Truth
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class NestedVectorStackTest {
+
+    @Test
+    fun testEnumerationOrder() {
+        val stack = NestedVectorStack<Int>()
+        stack.push(mutableVectorOf(1, 2, 3))
+        stack.push(mutableVectorOf(4, 5, 6))
+
+        Truth
+            .assertThat(stack.enumerate())
+            .isEqualTo(listOf(6, 5, 4, 3, 2, 1))
+    }
+
+    @Test
+    fun testEnumerationOrderPartiallyPoppingMiddleVectors() {
+        val stack = NestedVectorStack<Int>()
+        stack.push(mutableVectorOf(1, 2, 3))
+
+        Truth.assertThat(stack.pop()).isEqualTo(3)
+
+        stack.push(mutableVectorOf(4, 5, 6))
+
+        Truth.assertThat(stack.pop()).isEqualTo(6)
+
+        Truth
+            .assertThat(stack.enumerate())
+            .isEqualTo(listOf(5, 4, 2, 1))
+    }
+
+    @Test
+    fun testEnumerationOrderFullyPoppingMiddleVectors() {
+        val stack = NestedVectorStack<Int>()
+        stack.push(mutableVectorOf(1, 2, 3))
+
+        Truth.assertThat(stack.pop()).isEqualTo(3)
+        Truth.assertThat(stack.pop()).isEqualTo(2)
+        Truth.assertThat(stack.pop()).isEqualTo(1)
+
+        stack.push(mutableVectorOf(4, 5, 6))
+
+        Truth.assertThat(stack.pop()).isEqualTo(6)
+
+        Truth
+            .assertThat(stack.enumerate())
+            .isEqualTo(listOf(5, 4))
+    }
+}
+
+internal fun <T> NestedVectorStack<T>.enumerate(): List<T> {
+    val result = mutableListOf<T>()
+    var item: T? = pop()
+    while (item != null) {
+        result.add(item)
+        item = if (isNotEmpty()) pop() else null
+    }
+    return result
+}
diff --git a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutTest.java b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutTest.java
index 1c3f923..a6f0f11 100644
--- a/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutTest.java
+++ b/coordinatorlayout/coordinatorlayout/src/androidTest/java/androidx/coordinatorlayout/widget/CoordinatorLayoutTest.java
@@ -62,6 +62,7 @@
 import androidx.test.filters.SdkSuppress;
 
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -602,6 +603,7 @@
     }
 
     @Test
+    @Ignore("b/294608735")
     public void testNestedScrollingDispatchesToBehavior() throws Throwable {
         final CoordinatorLayoutActivity activity = mActivityTestRule.getActivity();
         final CoordinatorLayout col = activity.mCoordinatorLayout;
diff --git a/core/core-performance/samples/src/main/androidx/core/androidx-core-core-performance-samples-documentation.md b/core/core-performance/samples/src/main/androidx/core/androidx-core-core-performance-samples-documentation.md
index 1e79883..e1b2db1 100644
--- a/core/core-performance/samples/src/main/androidx/core/androidx-core-core-performance-samples-documentation.md
+++ b/core/core-performance/samples/src/main/androidx/core/androidx-core-core-performance-samples-documentation.md
@@ -1,7 +1,7 @@
 # Module root
 
-<GROUPID> <ARTIFACTID>
+Core Performance Usage
 
 # Package androidx.core.performance.samples
 
-Insert package level documentation here
+Sample application for using androidx.core.performance functionality.
\ No newline at end of file
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/BasicCallControlsTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/BasicCallControlsTest.kt
index 8abbb88..6ca77e6 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/BasicCallControlsTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/BasicCallControlsTest.kt
@@ -17,6 +17,7 @@
 package androidx.core.telecom.test
 
 import android.os.Build.VERSION_CODES
+import android.telecom.Call
 import android.telecom.DisconnectCause
 import androidx.annotation.RequiresApi
 import androidx.core.telecom.CallAttributesCompat
@@ -283,11 +284,14 @@
             val deferred = CompletableDeferred<Unit>()
             assertWithinTimeout_addCall(deferred, callAttributesCompat) {
                 launch {
+                    val call = TestUtils.waitOnInCallServiceToReachXCalls(1)
+                    assertNotNull("The returned Call object is <NULL>", call)
                     if (callAttributesCompat.isOutgoingCall()) {
                         assertTrue(setActive())
                     } else {
                         assertTrue(answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL))
                     }
+                    TestUtils.waitOnCallState(call!!, Call.STATE_ACTIVE)
                     assertTrue(disconnect(DisconnectCause(DisconnectCause.LOCAL)))
                     deferred.complete(Unit) // completed all asserts. cancel timeout!
                 }
@@ -301,9 +305,13 @@
             val deferred = CompletableDeferred<Unit>()
             assertWithinTimeout_addCall(deferred, callAttributesCompat) {
                 launch {
+                    val call = TestUtils.waitOnInCallServiceToReachXCalls(1)
+                    assertNotNull("The returned Call object is <NULL>", call)
                     repeat(NUM_OF_TIMES_TO_TOGGLE) {
                         assertTrue(setActive())
+                        TestUtils.waitOnCallState(call!!, Call.STATE_ACTIVE)
                         assertTrue(setInactive())
+                        TestUtils.waitOnCallState(call, Call.STATE_HOLDING)
                     }
                     assertTrue(disconnect(DisconnectCause(DisconnectCause.LOCAL)))
                     deferred.complete(Unit) // completed all asserts. cancel timeout!
@@ -317,7 +325,10 @@
             val deferred = CompletableDeferred<Unit>()
             assertWithinTimeout_addCall(deferred, callAttributesCompat) {
                 launch {
+                    val call = TestUtils.waitOnInCallServiceToReachXCalls(1)
+                    assertNotNull("The returned Call object is <NULL>", call)
                     assertTrue(setActive())
+                    TestUtils.waitOnCallState(call!!, Call.STATE_ACTIVE)
                     assertFalse(setInactive()) // API under test / expect failure
                     assertTrue(disconnect(DisconnectCause(DisconnectCause.LOCAL)))
                     deferred.complete(Unit) // completed all asserts. cancel timeout!
@@ -371,7 +382,10 @@
             val deferred = CompletableDeferred<Unit>()
             assertWithinTimeout_addCall(deferred, TestUtils.OUTGOING_CALL_ATTRIBUTES) {
                 launch {
+                    val call = TestUtils.waitOnInCallServiceToReachXCalls(1)
+                    assertNotNull("The returned Call object is <NULL>", call)
                     assertTrue(setActive())
+                    TestUtils.waitOnCallState(call!!, Call.STATE_ACTIVE)
                     // Grab initial mute state
                     val initialMuteState = isMuted.first()
                     // Toggle to other state
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/InCallAudioTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/InCallAudioTest.kt
index 48bc53d..74d846e 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/InCallAudioTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/InCallAudioTest.kt
@@ -37,6 +37,7 @@
 import org.junit.After
 import org.junit.Assert
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -79,6 +80,7 @@
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     @LargeTest
     @Test(timeout = 10000)
+    @Ignore
     fun testAddCallAssertModeInCommunication() {
         setUpV2Test()
         runBlocking_addCall_assertAudioModeInCommunication()
@@ -96,6 +98,7 @@
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
     @LargeTest
     @Test(timeout = 10000)
+    @Ignore
     fun testAddCallAssertModeInCommunication_BackwardsCompat() {
         setUpBackwardsCompatTest()
         runBlocking_addCall_assertAudioModeInCommunication()
diff --git a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbManagerImpl.kt b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbManagerImpl.kt
index 5aaae18..013f563 100644
--- a/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbManagerImpl.kt
+++ b/core/uwb/uwb/src/main/java/androidx/core/uwb/impl/UwbManagerImpl.kt
@@ -73,9 +73,12 @@
 
     private suspend fun createClientSessionScope(isController: Boolean): UwbClientSessionScope {
         checkSystemFeature(context)
+        val pm = context.packageManager
         val hasGmsCore = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(
             context, /* minApkVersion */230100000) == ConnectionResult.SUCCESS
-        return if (hasGmsCore) createGmsClientSessionScope(isController)
+        val isChinaGcoreDevice = pm.hasSystemFeature("cn.google.services") &&
+            pm.hasSystemFeature("com.google.android.feature.services_updater")
+        return if (hasGmsCore && !isChinaGcoreDevice) createGmsClientSessionScope(isController)
         else createAospClientSessionScope(isController)
     }
 
diff --git a/credentials/credentials-play-services-auth/src/androidTest/java/androidx/credentials/playservices/createpublickeycredential/PublicKeyCredentialControllerUtilityTest.kt b/credentials/credentials-play-services-auth/src/androidTest/java/androidx/credentials/playservices/createpublickeycredential/PublicKeyCredentialControllerUtilityTest.kt
index ede0dd7..72f7b84 100644
--- a/credentials/credentials-play-services-auth/src/androidTest/java/androidx/credentials/playservices/createpublickeycredential/PublicKeyCredentialControllerUtilityTest.kt
+++ b/credentials/credentials-play-services-auth/src/androidTest/java/androidx/credentials/playservices/createpublickeycredential/PublicKeyCredentialControllerUtilityTest.kt
@@ -72,7 +72,13 @@
       PublicKeyCredentialControllerUtility.Companion.JSON_KEY_EXTENSTIONS to "extensions",
       PublicKeyCredentialControllerUtility.Companion.JSON_KEY_ATTESTATION to "attestation",
       PublicKeyCredentialControllerUtility.Companion.JSON_KEY_PUB_KEY_CRED_PARAMS to
-        "pubKeyCredParams"
+        "pubKeyCredParams",
+      PublicKeyCredentialControllerUtility.Companion.JSON_KEY_CLIENT_EXTENSION_RESULTS to
+        "clientExtensionResults",
+      PublicKeyCredentialControllerUtility.Companion.JSON_KEY_CRED_PROPS to
+          "credProps",
+      PublicKeyCredentialControllerUtility.Companion.JSON_KEY_RK to
+          "rk"
     )
 
   private val TEST_REQUEST_JSON = "{\"hi\":{\"there\":{\"lol\":\"Value\"}}}"
@@ -151,6 +157,10 @@
     val publicKeyCredId = "id"
     val publicKeyCredRawId = byteArrayOf(0x48, 101, 108, 108, 115)
     val publicKeyCredType = "type"
+    val authenticatorAttachment = "platform"
+    val hasClientExtensionOutputs = true
+    val isDiscoverableCredential = true
+    val expectedClientExtensions = "{\"credProps\":{\"rk\":true}}"
 
     PublicKeyCredentialControllerUtility.beginSignInAssertionResponse(
       byteArrayClientDataJson,
@@ -160,7 +170,10 @@
       json,
       publicKeyCredId,
       publicKeyCredRawId,
-      publicKeyCredType
+      publicKeyCredType,
+      authenticatorAttachment,
+      hasClientExtensionOutputs,
+      isDiscoverableCredential
     )
 
     assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_ID))
@@ -169,6 +182,10 @@
       .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(publicKeyCredRawId))
     assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_TYPE))
       .isEqualTo(publicKeyCredType)
+    assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_AUTH_ATTACHMENT))
+      .isEqualTo(authenticatorAttachment)
+    assertThat(json.getJSONObject(PublicKeyCredentialControllerUtility
+      .JSON_KEY_CLIENT_EXTENSION_RESULTS).toString()).isEqualTo(expectedClientExtensions)
 
     // There is some embedded JSON so we should make sure we test that.
     var embeddedResponse =
@@ -181,6 +198,14 @@
       .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(byteArraySignature))
     assertThat(embeddedResponse.get(PublicKeyCredentialControllerUtility.JSON_KEY_USER_HANDLE))
       .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(byteArrayUserHandle))
+
+    // ClientExtensions are another group of embedded JSON
+    var clientExtensions = json.getJSONObject(PublicKeyCredentialControllerUtility
+      .JSON_KEY_CLIENT_EXTENSION_RESULTS)
+    assertThat(clientExtensions.get(PublicKeyCredentialControllerUtility.JSON_KEY_CRED_PROPS))
+      .isNotNull()
+    assertThat(clientExtensions.getJSONObject(PublicKeyCredentialControllerUtility
+      .JSON_KEY_CRED_PROPS).getBoolean(PublicKeyCredentialControllerUtility.JSON_KEY_RK)).isTrue()
   }
 
   fun toAssertPasskeyResponse_authenticatorAssertionResponse_noUserHandle_success() {
@@ -191,6 +216,8 @@
     val publicKeyCredId = "id"
     val publicKeyCredRawId = byteArrayOf(0x48, 101, 108, 108, 115)
     val publicKeyCredType = "type"
+    val authenticatorAttachment = "platform"
+    val hasClientExtensionOutputs = false
 
     PublicKeyCredentialControllerUtility.beginSignInAssertionResponse(
       byteArrayClientDataJson,
@@ -200,7 +227,10 @@
       json,
       publicKeyCredId,
       publicKeyCredRawId,
-      publicKeyCredType
+      publicKeyCredType,
+      authenticatorAttachment,
+      hasClientExtensionOutputs,
+      null
     )
 
     assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_ID))
@@ -209,6 +239,58 @@
       .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(publicKeyCredRawId))
     assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_TYPE))
       .isEqualTo(publicKeyCredType)
+    assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_AUTH_ATTACHMENT))
+      .isEqualTo(authenticatorAttachment)
+    assertThat(json.getJSONObject(PublicKeyCredentialControllerUtility
+      .JSON_KEY_CLIENT_EXTENSION_RESULTS).toString()).isEqualTo(JSONObject().toString())
+
+    // There is some embedded JSON so we should make sure we test that.
+    var embeddedResponse =
+      json.getJSONObject(PublicKeyCredentialControllerUtility.JSON_KEY_RESPONSE)
+    assertThat(embeddedResponse.get(PublicKeyCredentialControllerUtility.JSON_KEY_CLIENT_DATA))
+      .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(byteArrayClientDataJson))
+    assertThat(embeddedResponse.get(PublicKeyCredentialControllerUtility.JSON_KEY_AUTH_DATA))
+      .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(byteArrayAuthenticatorData))
+    assertThat(embeddedResponse.get(PublicKeyCredentialControllerUtility.JSON_KEY_SIGNATURE))
+      .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(byteArraySignature))
+    assertThat(embeddedResponse.has(PublicKeyCredentialControllerUtility.JSON_KEY_USER_HANDLE))
+      .isFalse()
+  }
+
+  fun toAssertPasskeyResponse_authenticatorAssertionResponse_noAuthenticatorAttachment_success() {
+    val byteArrayClientDataJson = byteArrayOf(0x48, 101, 108, 108, 111)
+    val byteArrayAuthenticatorData = byteArrayOf(0x48, 101, 108, 108, 112)
+    val byteArraySignature = byteArrayOf(0x48, 101, 108, 108, 113)
+    val json = JSONObject()
+    val publicKeyCredId = "id"
+    val publicKeyCredRawId = byteArrayOf(0x48, 101, 108, 108, 115)
+    val publicKeyCredType = "type"
+    val hasClientExtensionOutputs = false
+
+    PublicKeyCredentialControllerUtility.beginSignInAssertionResponse(
+      byteArrayClientDataJson,
+      byteArrayAuthenticatorData,
+      byteArraySignature,
+      null,
+      json,
+      publicKeyCredId,
+      publicKeyCredRawId,
+      publicKeyCredType,
+      null,
+      hasClientExtensionOutputs,
+      null
+    )
+
+    assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_ID))
+      .isEqualTo(publicKeyCredId)
+    assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_RAW_ID))
+      .isEqualTo(PublicKeyCredentialControllerUtility.b64Encode(publicKeyCredRawId))
+    assertThat(json.get(PublicKeyCredentialControllerUtility.JSON_KEY_TYPE))
+      .isEqualTo(publicKeyCredType)
+    assertThat(json.optJSONObject(PublicKeyCredentialControllerUtility.JSON_KEY_AUTH_ATTACHMENT))
+      .isNull()
+    assertThat(json.getJSONObject(PublicKeyCredentialControllerUtility
+      .JSON_KEY_CLIENT_EXTENSION_RESULTS).toString()).isEqualTo(JSONObject().toString())
 
     // There is some embedded JSON so we should make sure we test that.
     var embeddedResponse =
diff --git a/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/CredentialProviderCreatePublicKeyCredentialController.kt b/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/CredentialProviderCreatePublicKeyCredentialController.kt
index 1302a81..1a37158 100644
--- a/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/CredentialProviderCreatePublicKeyCredentialController.kt
+++ b/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/CredentialProviderCreatePublicKeyCredentialController.kt
@@ -186,8 +186,9 @@
     @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
     public override fun convertResponseToCredentialManager(response: PublicKeyCredential):
         CreateCredentialResponse {
-        return CreatePublicKeyCredentialResponse(PublicKeyCredentialControllerUtility
-            .toCreatePasskeyResponseJson(response))
+        return CreatePublicKeyCredentialResponse(
+            PublicKeyCredentialControllerUtility.toCreatePasskeyResponseJson(response)
+        )
     }
 
     private fun JSONExceptionToPKCError(exception: JSONException):
diff --git a/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/PublicKeyCredentialControllerUtility.kt b/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/PublicKeyCredentialControllerUtility.kt
index 1ed5d606..836f040 100644
--- a/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/PublicKeyCredentialControllerUtility.kt
+++ b/credentials/credentials-play-services-auth/src/main/java/androidx/credentials/playservices/controllers/CreatePublicKeyCredential/PublicKeyCredentialControllerUtility.kt
@@ -104,6 +104,9 @@
         internal val JSON_KEY_EXTENSTIONS = "extensions"
         internal val JSON_KEY_ATTESTATION = "attestation"
         internal val JSON_KEY_PUB_KEY_CRED_PARAMS = "pubKeyCredParams"
+        internal val JSON_KEY_CLIENT_EXTENSION_RESULTS = "clientExtensionResults"
+        internal val JSON_KEY_RK = "rk"
+        internal val JSON_KEY_CRED_PROPS = "credProps"
 
         /**
          * This function converts a request json to a PublicKeyCredentialCreationOptions, where
@@ -157,7 +160,12 @@
                     "got: ${authenticatorResponse.javaClass.name}")
             }
 
-            addOptionalAuthenticatorAttachmentAndExtensions(cred, json)
+            addOptionalAuthenticatorAttachmentAndRequiredExtensions(
+                cred.authenticatorAttachment,
+                cred.clientExtensionResults != null,
+                cred.clientExtensionResults?.credProps?.isDiscoverableCredential,
+                json
+            )
 
             json.put(JSON_KEY_ID, cred.id)
             json.put(JSON_KEY_RAW_ID, b64Encode(cred.rawId))
@@ -179,43 +187,37 @@
             return transportArray
         }
 
-        private fun addOptionalAuthenticatorAttachmentAndExtensions(
-            cred: PublicKeyCredential,
+        // This can be shared by both get and create flow response parsers
+        private fun addOptionalAuthenticatorAttachmentAndRequiredExtensions(
+            authenticatorAttachment: String?,
+            hasClientExtensionResults: Boolean,
+            isDiscoverableCredential: Boolean?,
             json: JSONObject
         ) {
-            val authenticatorAttachment = cred.authenticatorAttachment
-            val clientExtensionResults = cred.clientExtensionResults
 
             if (authenticatorAttachment != null) {
                 json.put(JSON_KEY_AUTH_ATTACHMENT, authenticatorAttachment)
             }
 
-            if (clientExtensionResults != null) {
+            val clientExtensionsJson = JSONObject()
+
+            if (hasClientExtensionResults) {
                 try {
-                    val uvmEntries = clientExtensionResults.uvmEntries
-                    val uvmEntriesList = uvmEntries?.uvmEntryList
-                    if (uvmEntriesList != null) {
-                        val uvmEntriesJSON = JSONArray()
-                        for (entry in uvmEntriesList) {
-                            val uvmEntryJSON = JSONObject()
-                            uvmEntryJSON.put(JSON_KEY_USER_VERIFICATION_METHOD,
-                                entry.userVerificationMethod)
-                            uvmEntryJSON.put(JSON_KEY_KEY_PROTECTION_TYPE, entry.keyProtectionType)
-                            uvmEntryJSON.put(
-                                JSON_KEY_MATCHER_PROTECTION_TYPE, entry.matcherProtectionType)
-                            uvmEntriesJSON.put(uvmEntryJSON)
-                        }
-                        json.put("uvm", uvmEntriesJSON)
+                    if (isDiscoverableCredential != null) {
+                        val credPropsObject = JSONObject()
+                        credPropsObject.put(JSON_KEY_RK, isDiscoverableCredential)
+                        clientExtensionsJson.put(JSON_KEY_CRED_PROPS, credPropsObject)
                     }
                 } catch (t: Throwable) {
                     Log.e(TAG, "ClientExtensionResults faced possible implementation " +
                         "inconsistency in uvmEntries - $t")
                 }
             }
+            json.put(JSON_KEY_CLIENT_EXTENSION_RESULTS, clientExtensionsJson)
         }
 
         fun toAssertPasskeyResponse(cred: SignInCredential): String {
-            val json = JSONObject()
+            var json = JSONObject()
             val publicKeyCred = cred.publicKeyCredential
 
             when (val authenticatorResponse = publicKeyCred?.response!!) {
@@ -233,7 +235,11 @@
                         json,
                         publicKeyCred.id,
                         publicKeyCred.rawId,
-                        publicKeyCred.type)
+                        publicKeyCred.type,
+                        publicKeyCred.authenticatorAttachment,
+                        publicKeyCred.clientExtensionResults != null,
+                        publicKeyCred.clientExtensionResults?.credProps?.isDiscoverableCredential
+                    )
                 }
                 else -> {
                 Log.e(
@@ -253,7 +259,10 @@
             json: JSONObject,
             publicKeyCredId: String,
             publicKeyCredRawId: ByteArray,
-            publicKeyCredType: String
+            publicKeyCredType: String,
+            authenticatorAttachment: String?,
+            hasClientExtensionResults: Boolean,
+            isDiscoverableCredential: Boolean?
         ) {
             val responseJson = JSONObject()
             responseJson.put(
@@ -277,6 +286,12 @@
             json.put(JSON_KEY_ID, publicKeyCredId)
             json.put(JSON_KEY_RAW_ID, b64Encode(publicKeyCredRawId))
             json.put(JSON_KEY_TYPE, publicKeyCredType)
+            addOptionalAuthenticatorAttachmentAndRequiredExtensions(
+                authenticatorAttachment,
+                hasClientExtensionResults,
+                isDiscoverableCredential,
+                json
+            )
         }
 
         /**
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index a8cae27..777f11c 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -8,55 +8,57 @@
 }
 
 dependencies {
-    docs("androidx.activity:activity:1.8.0-alpha06")
-    docs("androidx.activity:activity-compose:1.8.0-alpha06")
+    apiSinceDocs("androidx.activity:activity:1.8.0-alpha06")
+    apiSinceDocs("androidx.activity:activity-compose:1.8.0-alpha06")
     samples("androidx.activity:activity-compose-samples:1.8.0-alpha06")
-    docs("androidx.activity:activity-ktx:1.8.0-alpha06")
+    apiSinceDocs("androidx.activity:activity-ktx:1.8.0-alpha06")
+    // ads-identifier is deprecated
     docs("androidx.ads:ads-identifier:1.0.0-alpha05")
     docs("androidx.ads:ads-identifier-common:1.0.0-alpha05")
     docs("androidx.ads:ads-identifier-provider:1.0.0-alpha05")
     kmpDocs("androidx.annotation:annotation:1.7.0-alpha03")
-    docs("androidx.annotation:annotation-experimental:1.4.0-alpha01")
-    docs("androidx.appcompat:appcompat:1.7.0-alpha03")
-    docs("androidx.appcompat:appcompat-resources:1.7.0-alpha03")
-    docs("androidx.appsearch:appsearch:1.1.0-alpha04")
-    docs("androidx.appsearch:appsearch-builtin-types:1.1.0-alpha04")
-    docs("androidx.appsearch:appsearch-ktx:1.1.0-alpha04")
-    docs("androidx.appsearch:appsearch-local-storage:1.1.0-alpha04")
-    docs("androidx.appsearch:appsearch-platform-storage:1.1.0-alpha04")
-    docs("androidx.appsearch:appsearch-play-services-storage:1.1.0-alpha04")
-    docs("androidx.arch.core:core-common:2.2.0")
-    docs("androidx.arch.core:core-runtime:2.2.0")
-    docs("androidx.arch.core:core-testing:2.2.0")
-    docs("androidx.asynclayoutinflater:asynclayoutinflater:1.1.0-alpha01")
-    docs("androidx.asynclayoutinflater:asynclayoutinflater-appcompat:1.1.0-alpha01")
-    docs("androidx.autofill:autofill:1.3.0-alpha01")
-    docs("androidx.benchmark:benchmark-common:1.2.0-beta02")
-    docs("androidx.benchmark:benchmark-junit4:1.2.0-beta02")
-    docs("androidx.benchmark:benchmark-macro:1.2.0-beta02")
-    docs("androidx.benchmark:benchmark-macro-junit4:1.2.0-beta02")
-    docs("androidx.biometric:biometric:1.2.0-alpha05")
-    docs("androidx.biometric:biometric-ktx:1.2.0-alpha05")
+    apiSinceDocs("androidx.annotation:annotation-experimental:1.4.0-alpha01")
+    apiSinceDocs("androidx.appcompat:appcompat:1.7.0-alpha03")
+    apiSinceDocs("androidx.appcompat:appcompat-resources:1.7.0-alpha03")
+    apiSinceDocs("androidx.appsearch:appsearch:1.1.0-alpha04")
+    apiSinceDocs("androidx.appsearch:appsearch-builtin-types:1.1.0-alpha04")
+    apiSinceDocs("androidx.appsearch:appsearch-ktx:1.1.0-alpha04")
+    apiSinceDocs("androidx.appsearch:appsearch-local-storage:1.1.0-alpha04")
+    apiSinceDocs("androidx.appsearch:appsearch-platform-storage:1.1.0-alpha04")
+    apiSinceDocs("androidx.appsearch:appsearch-play-services-storage:1.1.0-alpha04")
+    apiSinceDocs("androidx.arch.core:core-common:2.2.0")
+    apiSinceDocs("androidx.arch.core:core-runtime:2.2.0")
+    apiSinceDocs("androidx.arch.core:core-testing:2.2.0")
+    apiSinceDocs("androidx.asynclayoutinflater:asynclayoutinflater:1.1.0-alpha01")
+    apiSinceDocs("androidx.asynclayoutinflater:asynclayoutinflater-appcompat:1.1.0-alpha01")
+    apiSinceDocs("androidx.autofill:autofill:1.3.0-alpha01")
+    apiSinceDocs("androidx.benchmark:benchmark-common:1.2.0-beta02")
+    apiSinceDocs("androidx.benchmark:benchmark-junit4:1.2.0-beta02")
+    apiSinceDocs("androidx.benchmark:benchmark-macro:1.2.0-beta02")
+    apiSinceDocs("androidx.benchmark:benchmark-macro-junit4:1.2.0-beta02")
+    apiSinceDocs("androidx.biometric:biometric:1.2.0-alpha05")
+    apiSinceDocs("androidx.biometric:biometric-ktx:1.2.0-alpha05")
     samples("androidx.biometric:biometric-ktx-samples:1.2.0-alpha05")
-    docs("androidx.browser:browser:1.6.0-rc01")
-    docs("androidx.camera:camera-camera2:1.3.0-beta02")
-    docs("androidx.camera:camera-core:1.3.0-beta02")
-    docs("androidx.camera:camera-extensions:1.3.0-beta02")
+    apiSinceDocs("androidx.browser:browser:1.6.0-rc01")
+    apiSinceDocs("androidx.camera:camera-camera2:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-core:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-extensions:1.3.0-beta02")
     stubs(fileTree(dir: "../camera/camera-extensions-stub", include: ["camera-extensions-stub.jar"]))
-    docs("androidx.camera:camera-lifecycle:1.3.0-beta02")
-    docs("androidx.camera:camera-mlkit-vision:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-lifecycle:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-mlkit-vision:1.3.0-beta02")
+    // camera-previewview is not hosted in androidx
     docs("androidx.camera:camera-previewview:1.1.0-beta02")
-    docs("androidx.camera:camera-video:1.3.0-beta02")
-    docs("androidx.camera:camera-view:1.3.0-beta02")
-    docs("androidx.camera:camera-viewfinder:1.3.0-beta02")
-    docs("androidx.camera:camera-viewfinder-core:1.3.0-beta02")
-    docs("androidx.car.app:app:1.4.0-alpha02")
-    docs("androidx.car.app:app-automotive:1.4.0-alpha02")
-    docs("androidx.car.app:app-projected:1.4.0-alpha02")
-    docs("androidx.car.app:app-testing:1.4.0-alpha02")
-    docs("androidx.cardview:cardview:1.0.0")
+    apiSinceDocs("androidx.camera:camera-video:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-view:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-viewfinder:1.3.0-beta02")
+    apiSinceDocs("androidx.camera:camera-viewfinder-core:1.3.0-beta02")
+    apiSinceDocs("androidx.car.app:app:1.4.0-alpha02")
+    apiSinceDocs("androidx.car.app:app-automotive:1.4.0-alpha02")
+    apiSinceDocs("androidx.car.app:app-projected:1.4.0-alpha02")
+    apiSinceDocs("androidx.car.app:app-testing:1.4.0-alpha02")
+    apiSinceDocs("androidx.cardview:cardview:1.0.0")
     kmpDocs("androidx.collection:collection:1.3.0-alpha03")
-    docs("androidx.collection:collection-ktx:1.3.0-alpha03")
+    apiSinceDocs("androidx.collection:collection-ktx:1.3.0-alpha03")
     kmpDocs("androidx.compose.animation:animation:1.6.0-alpha02")
     kmpDocs("androidx.compose.animation:animation-core:1.6.0-alpha02")
     kmpDocs("androidx.compose.animation:animation-graphics:1.6.0-alpha02")
@@ -77,16 +79,16 @@
     kmpDocs("androidx.compose.material:material-ripple:1.6.0-alpha02")
     samples("androidx.compose.material:material-samples:1.6.0-alpha02")
     kmpDocs("androidx.compose.runtime:runtime:1.6.0-alpha02")
-    docs("androidx.compose.runtime:runtime-livedata:1.6.0-alpha02")
+    apiSinceDocs("androidx.compose.runtime:runtime-livedata:1.6.0-alpha02")
     samples("androidx.compose.runtime:runtime-livedata-samples:1.6.0-alpha02")
-    docs("androidx.compose.runtime:runtime-rxjava2:1.6.0-alpha02")
+    apiSinceDocs("androidx.compose.runtime:runtime-rxjava2:1.6.0-alpha02")
     samples("androidx.compose.runtime:runtime-rxjava2-samples:1.6.0-alpha02")
-    docs("androidx.compose.runtime:runtime-rxjava3:1.6.0-alpha02")
+    apiSinceDocs("androidx.compose.runtime:runtime-rxjava3:1.6.0-alpha02")
     samples("androidx.compose.runtime:runtime-rxjava3-samples:1.6.0-alpha02")
     kmpDocs("androidx.compose.runtime:runtime-saveable:1.6.0-alpha02")
     samples("androidx.compose.runtime:runtime-saveable-samples:1.6.0-alpha02")
     samples("androidx.compose.runtime:runtime-samples:1.6.0-alpha02")
-    docs("androidx.compose.runtime:runtime-tracing:1.0.0-alpha03")
+    apiSinceDocs("androidx.compose.runtime:runtime-tracing:1.0.0-alpha03")
     kmpDocs("androidx.compose.ui:ui:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-geometry:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-graphics:1.6.0-alpha02")
@@ -95,7 +97,7 @@
     kmpDocs("androidx.compose.ui:ui-test-junit4:1.6.0-alpha02")
     samples("androidx.compose.ui:ui-test-samples:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-text:1.6.0-alpha02")
-    docs("androidx.compose.ui:ui-text-google-fonts:1.6.0-alpha02")
+    apiSinceDocs("androidx.compose.ui:ui-text-google-fonts:1.6.0-alpha02")
     samples("androidx.compose.ui:ui-text-samples:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-tooling:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-tooling-data:1.6.0-alpha02")
@@ -103,124 +105,129 @@
     kmpDocs("androidx.compose.ui:ui-unit:1.6.0-alpha02")
     samples("androidx.compose.ui:ui-unit-samples:1.6.0-alpha02")
     kmpDocs("androidx.compose.ui:ui-util:1.6.0-alpha02")
-    docs("androidx.compose.ui:ui-viewbinding:1.6.0-alpha02")
+    apiSinceDocs("androidx.compose.ui:ui-viewbinding:1.6.0-alpha02")
     samples("androidx.compose.ui:ui-viewbinding-samples:1.6.0-alpha02")
     samples("androidx.compose.ui:ui-samples:1.6.0-alpha02")
-    docs("androidx.concurrent:concurrent-futures:1.2.0-alpha01")
-    docs("androidx.concurrent:concurrent-futures-ktx:1.2.0-alpha01")
-    docs("androidx.constraintlayout:constraintlayout:2.2.0-alpha11")
+    apiSinceDocs("androidx.concurrent:concurrent-futures:1.2.0-alpha01")
+    apiSinceDocs("androidx.concurrent:concurrent-futures-ktx:1.2.0-alpha01")
+    apiSinceDocs("androidx.constraintlayout:constraintlayout:2.2.0-alpha11")
     kmpDocs("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha11")
-    docs("androidx.constraintlayout:constraintlayout-core:1.1.0-alpha11")
-    docs("androidx.contentpager:contentpager:1.0.0")
-    docs("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
-    docs("androidx.core:core:1.12.0-beta01")
+    apiSinceDocs("androidx.constraintlayout:constraintlayout-core:1.1.0-alpha11")
+    apiSinceDocs("androidx.contentpager:contentpager:1.0.0")
+    apiSinceDocs("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
+    apiSinceDocs("androidx.core:core:1.12.0-beta01")
+    // TODO(b/294531403): Turn on apiSince for core-animation when it releases as alpha
     docs("androidx.core:core-animation:1.0.0-rc01")
     docs("androidx.core:core-animation-testing:1.0.0-rc01")
-    docs("androidx.core:core-google-shortcuts:1.2.0-alpha01")
-    docs("androidx.core:core-i18n:1.0.0-alpha01")
-    docs("androidx.core:core-ktx:1.12.0-beta01")
-    docs("androidx.core:core-location-altitude:1.0.0-alpha01")
-    docs("androidx.core:core-performance:1.0.0-alpha03")
+    apiSinceDocs("androidx.core:core-google-shortcuts:1.2.0-alpha01")
+    apiSinceDocs("androidx.core:core-i18n:1.0.0-alpha01")
+    apiSinceDocs("androidx.core:core-ktx:1.12.0-beta01")
+    apiSinceDocs("androidx.core:core-location-altitude:1.0.0-alpha01")
+    apiSinceDocs("androidx.core:core-performance:1.0.0-alpha03")
     samples("androidx.core:core-performance-samples:1.0.0-alpha03")
-    docs("androidx.core:core-remoteviews:1.0.0-rc01")
-    docs("androidx.core:core-role:1.2.0-alpha01")
-    docs("androidx.core:core-splashscreen:1.1.0-alpha01")
-    docs("androidx.core:core-telecom:1.0.0-alpha01")
-    docs("androidx.core:core-testing:1.12.0-beta01")
-    docs("androidx.core.uwb:uwb:1.0.0-alpha06")
-    docs("androidx.core.uwb:uwb-rxjava3:1.0.0-alpha06")
-    docs("androidx.credentials:credentials:1.2.0-beta02")
-    docs("androidx.credentials:credentials-play-services-auth:1.2.0-beta02")
+    apiSinceDocs("androidx.core:core-remoteviews:1.0.0-rc01")
+    apiSinceDocs("androidx.core:core-role:1.2.0-alpha01")
+    apiSinceDocs("androidx.core:core-splashscreen:1.1.0-alpha01")
+    apiSinceDocs("androidx.core:core-telecom:1.0.0-alpha01")
+    apiSinceDocs("androidx.core:core-testing:1.12.0-beta01")
+    apiSinceDocs("androidx.core.uwb:uwb:1.0.0-alpha06")
+    apiSinceDocs("androidx.core.uwb:uwb-rxjava3:1.0.0-alpha06")
+    apiSinceDocs("androidx.credentials:credentials:1.2.0-beta02")
+    apiSinceDocs("androidx.credentials:credentials-play-services-auth:1.2.0-beta02")
     samples("androidx.credentials:credentials-samples:1.2.0-beta02")
-    docs("androidx.cursoradapter:cursoradapter:1.0.0")
-    docs("androidx.customview:customview:1.2.0-alpha02")
+    apiSinceDocs("androidx.cursoradapter:cursoradapter:1.0.0")
+    apiSinceDocs("androidx.customview:customview:1.2.0-alpha02")
+    // TODO(b/294531403): Turn on apiSince for customview-poolingcontainer when it releases as alpha
     docs("androidx.customview:customview-poolingcontainer:1.0.0-rc01")
     kmpDocs("androidx.datastore:datastore:1.1.0-alpha04")
     kmpDocs("androidx.datastore:datastore-core:1.1.0-alpha04")
     kmpDocs("androidx.datastore:datastore-core-okio:1.1.0-alpha04")
     kmpDocs("androidx.datastore:datastore-preferences:1.1.0-alpha04")
     kmpDocs("androidx.datastore:datastore-preferences-core:1.1.0-alpha04")
-    docs("androidx.datastore:datastore-preferences-rxjava2:1.1.0-alpha04")
-    docs("androidx.datastore:datastore-preferences-rxjava3:1.1.0-alpha04")
-    docs("androidx.datastore:datastore-rxjava2:1.1.0-alpha04")
-    docs("androidx.datastore:datastore-rxjava3:1.1.0-alpha04")
-    docs("androidx.documentfile:documentfile:1.1.0-alpha01")
-    docs("androidx.draganddrop:draganddrop:1.0.0")
-    docs("androidx.drawerlayout:drawerlayout:1.2.0")
-    docs("androidx.dynamicanimation:dynamicanimation:1.1.0-alpha02")
-    docs("androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03")
-    docs("androidx.emoji2:emoji2:1.4.0")
-    docs("androidx.emoji2:emoji2-bundled:1.4.0")
-    docs("androidx.emoji2:emoji2-emojipicker:1.4.0")
-    docs("androidx.emoji2:emoji2-views:1.4.0")
-    docs("androidx.emoji2:emoji2-views-helper:1.4.0")
-    docs("androidx.emoji:emoji:1.2.0-alpha03")
-    docs("androidx.emoji:emoji-appcompat:1.2.0-alpha03")
-    docs("androidx.emoji:emoji-bundled:1.2.0-alpha03")
-    docs("androidx.enterprise:enterprise-feedback:1.1.0")
-    docs("androidx.enterprise:enterprise-feedback-testing:1.1.0")
-    docs("androidx.exifinterface:exifinterface:1.3.6")
-    docs("androidx.fragment:fragment:1.7.0-alpha01")
-    docs("androidx.fragment:fragment-ktx:1.7.0-alpha01")
-    docs("androidx.fragment:fragment-testing:1.7.0-alpha01")
-    docs("androidx.glance:glance:1.0.0-rc01")
-    docs("androidx.glance:glance-appwidget:1.0.0-rc01")
+    apiSinceDocs("androidx.datastore:datastore-preferences-rxjava2:1.1.0-alpha04")
+    apiSinceDocs("androidx.datastore:datastore-preferences-rxjava3:1.1.0-alpha04")
+    apiSinceDocs("androidx.datastore:datastore-rxjava2:1.1.0-alpha04")
+    apiSinceDocs("androidx.datastore:datastore-rxjava3:1.1.0-alpha04")
+    apiSinceDocs("androidx.documentfile:documentfile:1.1.0-alpha01")
+    apiSinceDocs("androidx.draganddrop:draganddrop:1.0.0")
+    apiSinceDocs("androidx.drawerlayout:drawerlayout:1.2.0")
+    apiSinceDocs("androidx.dynamicanimation:dynamicanimation:1.1.0-alpha02")
+    apiSinceDocs("androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03")
+    apiSinceDocs("androidx.emoji2:emoji2:1.4.0-rc01")
+    apiSinceDocs("androidx.emoji2:emoji2-bundled:1.4.0-rc01")
+    apiSinceDocs("androidx.emoji2:emoji2-emojipicker:1.4.0-rc01")
+    apiSinceDocs("androidx.emoji2:emoji2-views:1.4.0-rc01")
+    apiSinceDocs("androidx.emoji2:emoji2-views-helper:1.4.0-rc01")
+    apiSinceDocs("androidx.emoji:emoji:1.2.0-alpha03")
+    apiSinceDocs("androidx.emoji:emoji-appcompat:1.2.0-alpha03")
+    apiSinceDocs("androidx.emoji:emoji-bundled:1.2.0-alpha03")
+    apiSinceDocs("androidx.enterprise:enterprise-feedback:1.1.0")
+    apiSinceDocs("androidx.enterprise:enterprise-feedback-testing:1.1.0")
+    apiSinceDocs("androidx.exifinterface:exifinterface:1.3.6")
+    apiSinceDocs("androidx.fragment:fragment:1.7.0-alpha01")
+    apiSinceDocs("androidx.fragment:fragment-ktx:1.7.0-alpha01")
+    apiSinceDocs("androidx.fragment:fragment-testing:1.7.0-alpha01")
+    apiSinceDocs("androidx.glance:glance:1.0.0-rc01")
+    apiSinceDocs("androidx.glance:glance-appwidget:1.0.0-rc01")
     samples("androidx.glance:glance-appwidget-samples:1.0.0-rc01")
-    docs("androidx.glance:glance-appwidget-preview:1.0.0-alpha06")
-    docs("androidx.glance:glance-material:1.0.0-rc01")
-    docs("androidx.glance:glance-material3:1.0.0-rc01")
-    docs("androidx.glance:glance-preview:1.0.0-alpha06")
-    docs("androidx.glance:glance-template:1.0.0-alpha06")
-    docs("androidx.glance:glance-wear-tiles:1.0.0-alpha06")
-    docs("androidx.glance:glance-wear-tiles-preview:1.0.0-alpha06")
-    docs("androidx.graphics:graphics-core:1.0.0-alpha04")
-    docs("androidx.gridlayout:gridlayout:1.1.0-beta01")
-    docs("androidx.health.connect:connect-client:1.1.0-alpha03")
+    apiSinceDocs("androidx.glance:glance-appwidget-preview:1.0.0-alpha06")
+    apiSinceDocs("androidx.glance:glance-material:1.0.0-rc01")
+    apiSinceDocs("androidx.glance:glance-material3:1.0.0-rc01")
+    apiSinceDocs("androidx.glance:glance-preview:1.0.0-alpha06")
+    apiSinceDocs("androidx.glance:glance-template:1.0.0-alpha06")
+    apiSinceDocs("androidx.glance:glance-wear-tiles:1.0.0-alpha06")
+    apiSinceDocs("androidx.glance:glance-wear-tiles-preview:1.0.0-alpha06")
+    apiSinceDocs("androidx.graphics:graphics-core:1.0.0-alpha04")
+    apiSinceDocs("androidx.gridlayout:gridlayout:1.1.0-beta01")
+    apiSinceDocs("androidx.health.connect:connect-client:1.1.0-alpha03")
     samples("androidx.health.connect:connect-client-samples:1.1.0-alpha03")
+    // TODO(b/294531403): Turn on apiSince for health-services-client when it releases as alpha
     docs("androidx.health:health-services-client:1.0.0-rc01")
-    docs("androidx.heifwriter:heifwriter:1.1.0-alpha02")
-    docs("androidx.hilt:hilt-common:1.0.0-beta01")
-    docs("androidx.hilt:hilt-navigation:1.1.0-alpha02")
-    docs("androidx.hilt:hilt-navigation-compose:1.1.0-alpha01")
+    apiSinceDocs("androidx.heifwriter:heifwriter:1.1.0-alpha02")
+    apiSinceDocs("androidx.hilt:hilt-common:1.0.0-beta01")
+    apiSinceDocs("androidx.hilt:hilt-navigation:1.1.0-alpha02")
+    apiSinceDocs("androidx.hilt:hilt-navigation-compose:1.1.0-alpha01")
     samples("androidx.hilt:hilt-navigation-compose-samples:1.1.0-alpha01")
-    docs("androidx.hilt:hilt-navigation-fragment:1.1.0-alpha02")
-    docs("androidx.hilt:hilt-work:1.0.0-beta01")
-    docs("androidx.input:input-motionprediction:1.0.0-beta02")
-    docs("androidx.interpolator:interpolator:1.0.0")
-    docs("androidx.javascriptengine:javascriptengine:1.0.0-alpha05")
-    docs("androidx.leanback:leanback:1.2.0-alpha02")
-    docs("androidx.leanback:leanback-grid:1.0.0-alpha01")
-    docs("androidx.leanback:leanback-paging:1.1.0-alpha09")
-    docs("androidx.leanback:leanback-preference:1.2.0-alpha02")
-    docs("androidx.leanback:leanback-tab:1.1.0-beta01")
-    docs("androidx.lifecycle:lifecycle-common:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-common-java8:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-extensions:2.2.0")
-    docs("androidx.lifecycle:lifecycle-livedata:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-livedata-core:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-process:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-reactivestreams:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-reactivestreams-ktx:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-runtime:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-runtime-compose:2.7.0-alpha01")
+    apiSinceDocs("androidx.hilt:hilt-navigation-fragment:1.1.0-alpha02")
+    apiSinceDocs("androidx.hilt:hilt-work:1.0.0-beta01")
+    apiSinceDocs("androidx.input:input-motionprediction:1.0.0-beta02")
+    apiSinceDocs("androidx.interpolator:interpolator:1.0.0")
+    apiSinceDocs("androidx.javascriptengine:javascriptengine:1.0.0-alpha05")
+    apiSinceDocs("androidx.leanback:leanback:1.2.0-alpha02")
+    apiSinceDocs("androidx.leanback:leanback-grid:1.0.0-alpha01")
+    apiSinceDocs("androidx.leanback:leanback-paging:1.1.0-alpha09")
+    apiSinceDocs("androidx.leanback:leanback-preference:1.2.0-alpha02")
+    apiSinceDocs("androidx.leanback:leanback-tab:1.1.0-beta01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-common:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-common-java8:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-extensions:2.2.0")
+    apiSinceDocs("androidx.lifecycle:lifecycle-livedata:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-livedata-core:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-process:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-reactivestreams:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-reactivestreams-ktx:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-runtime:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-runtime-compose:2.7.0-alpha01")
     samples("androidx.lifecycle:lifecycle-runtime-compose-samples:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-runtime-testing:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-service:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-viewmodel:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-runtime-testing:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-service:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-viewmodel:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0-alpha01")
     samples("androidx.lifecycle:lifecycle-viewmodel-compose-samples:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0-alpha01")
-    docs("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0-alpha01")
-    docs("androidx.loader:loader:1.1.0")
+    apiSinceDocs("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0-alpha01")
+    apiSinceDocs("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0-alpha01")
+    apiSinceDocs("androidx.loader:loader:1.1.0")
+    // localbroadcastmanager is deprecated
     docs("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
-    docs("androidx.media2:media2-common:1.2.1")
-    docs("androidx.media2:media2-player:1.2.1")
-    docs("androidx.media2:media2-session:1.2.1")
-    docs("androidx.media2:media2-widget:1.2.1")
-    docs("androidx.media:media:1.6.0")
+    apiSinceDocs("androidx.media2:media2-common:1.2.1")
+    apiSinceDocs("androidx.media2:media2-player:1.2.1")
+    apiSinceDocs("androidx.media2:media2-session:1.2.1")
+    apiSinceDocs("androidx.media2:media2-widget:1.2.1")
+    apiSinceDocs("androidx.media:media:1.6.0")
+    // androidx.media3 is not hosted in androidx
     docs("androidx.media3:media3-cast:1.1.0")
     docs("androidx.media3:media3-common:1.1.0")
     docs("androidx.media3:media3-container:1.1.0")
@@ -246,85 +253,85 @@
     docs("androidx.media3:media3-transformer:1.1.0")
     docs("androidx.media3:media3-ui:1.1.0")
     docs("androidx.media3:media3-ui-leanback:1.1.0")
-    docs("androidx.mediarouter:mediarouter:1.6.0-alpha05")
-    docs("androidx.mediarouter:mediarouter-testing:1.6.0-alpha05")
-    docs("androidx.metrics:metrics-performance:1.0.0-alpha04")
-    docs("androidx.navigation:navigation-common:2.7.0-rc01")
-    docs("androidx.navigation:navigation-common-ktx:2.7.0-rc01")
-    docs("androidx.navigation:navigation-compose:2.7.0-rc01")
+    apiSinceDocs("androidx.mediarouter:mediarouter:1.6.0-alpha05")
+    apiSinceDocs("androidx.mediarouter:mediarouter-testing:1.6.0-alpha05")
+    apiSinceDocs("androidx.metrics:metrics-performance:1.0.0-alpha04")
+    apiSinceDocs("androidx.navigation:navigation-common:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-common-ktx:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-compose:2.7.0-rc01")
     samples("androidx.navigation:navigation-compose-samples:2.7.0-rc01")
-    docs("androidx.navigation:navigation-dynamic-features-fragment:2.7.0-rc01")
-    docs("androidx.navigation:navigation-dynamic-features-runtime:2.7.0-rc01")
-    docs("androidx.navigation:navigation-fragment:2.7.0-rc01")
-    docs("androidx.navigation:navigation-fragment-ktx:2.7.0-rc01")
-    docs("androidx.navigation:navigation-runtime:2.7.0-rc01")
-    docs("androidx.navigation:navigation-runtime-ktx:2.7.0-rc01")
-    docs("androidx.navigation:navigation-testing:2.7.0-rc01")
-    docs("androidx.navigation:navigation-ui:2.7.0-rc01")
-    docs("androidx.navigation:navigation-ui-ktx:2.7.0-rc01")
-    docs("androidx.paging:paging-common:3.2.0")
-    docs("androidx.paging:paging-common-ktx:3.2.0")
-    docs("androidx.paging:paging-compose:3.2.0")
+    apiSinceDocs("androidx.navigation:navigation-dynamic-features-fragment:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-dynamic-features-runtime:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-fragment:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-fragment-ktx:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-runtime:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-runtime-ktx:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-testing:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-ui:2.7.0-rc01")
+    apiSinceDocs("androidx.navigation:navigation-ui-ktx:2.7.0-rc01")
+    apiSinceDocs("androidx.paging:paging-common:3.2.0")
+    apiSinceDocs("androidx.paging:paging-common-ktx:3.2.0")
+    apiSinceDocs("androidx.paging:paging-compose:3.2.0")
     samples("androidx.paging:paging-compose-samples:3.2.0")
-    docs("androidx.paging:paging-guava:3.2.0")
-    docs("androidx.paging:paging-runtime:3.2.0")
-    docs("androidx.paging:paging-runtime-ktx:3.2.0")
-    docs("androidx.paging:paging-rxjava2:3.2.0")
-    docs("androidx.paging:paging-rxjava2-ktx:3.2.0")
-    docs("androidx.paging:paging-rxjava3:3.2.0")
+    apiSinceDocs("androidx.paging:paging-guava:3.2.0")
+    apiSinceDocs("androidx.paging:paging-runtime:3.2.0")
+    apiSinceDocs("androidx.paging:paging-runtime-ktx:3.2.0")
+    apiSinceDocs("androidx.paging:paging-rxjava2:3.2.0")
+    apiSinceDocs("androidx.paging:paging-rxjava2-ktx:3.2.0")
+    apiSinceDocs("androidx.paging:paging-rxjava3:3.2.0")
     samples("androidx.paging:paging-samples:3.2.0")
-    docs("androidx.paging:paging-testing:3.2.0")
-    docs("androidx.palette:palette:1.0.0")
-    docs("androidx.palette:palette-ktx:1.0.0")
-    docs("androidx.percentlayout:percentlayout:1.0.1")
-    docs("androidx.preference:preference:1.2.1")
-    docs("androidx.preference:preference-ktx:1.2.1")
-    docs("androidx.print:print:1.1.0-beta01")
-    docs("androidx.privacysandbox.ads:ads-adservices:1.1.0-alpha01")
-    docs("androidx.privacysandbox.ads:ads-adservices-java:1.1.0-alpha01")
-    docs("androidx.privacysandbox.sdkruntime:sdkruntime-client:1.0.0-alpha07")
-    docs("androidx.privacysandbox.sdkruntime:sdkruntime-core:1.0.0-alpha07")
-    docs("androidx.privacysandbox.tools:tools:1.0.0-alpha04")
-    docs("androidx.privacysandbox.ui:ui-client:1.0.0-alpha04")
-    docs("androidx.privacysandbox.ui:ui-core:1.0.0-alpha04")
-    docs("androidx.privacysandbox.ui:ui-provider:1.0.0-alpha04")
-    docs("androidx.profileinstaller:profileinstaller:1.3.1")
-    docs("androidx.recommendation:recommendation:1.0.0")
-    docs("androidx.recyclerview:recyclerview:1.3.1")
-    docs("androidx.recyclerview:recyclerview-selection:2.0.0-alpha01")
-    docs("androidx.remotecallback:remotecallback:1.0.0-alpha02")
-    docs("androidx.resourceinspection:resourceinspection-annotation:1.0.1")
-    docs("androidx.resourceinspection:resourceinspection-processor:1.0.1")
-    docs("androidx.room:room-common:2.6.0-alpha02")
-    docs("androidx.room:room-guava:2.6.0-alpha02")
-    docs("androidx.room:room-ktx:2.6.0-alpha02")
-    docs("androidx.room:room-migration:2.6.0-alpha02")
-    docs("androidx.room:room-paging:2.6.0-alpha02")
-    docs("androidx.room:room-paging-guava:2.6.0-alpha02")
-    docs("androidx.room:room-paging-rxjava2:2.6.0-alpha02")
-    docs("androidx.room:room-paging-rxjava3:2.6.0-alpha02")
-    docs("androidx.room:room-runtime:2.6.0-alpha02")
-    docs("androidx.room:room-rxjava2:2.6.0-alpha02")
-    docs("androidx.room:room-rxjava3:2.6.0-alpha02")
-    docs("androidx.room:room-testing:2.6.0-alpha02")
-    docs("androidx.savedstate:savedstate:1.2.1")
-    docs("androidx.savedstate:savedstate-ktx:1.2.1")
-    docs("androidx.security:security-app-authenticator:1.0.0-alpha02")
-    docs("androidx.security:security-app-authenticator-testing:1.0.0-alpha01")
-    docs("androidx.security:security-crypto:1.1.0-alpha06")
-    docs("androidx.security:security-crypto-ktx:1.1.0-alpha06")
-    docs("androidx.security:security-identity-credential:1.0.0-alpha03")
-    docs("androidx.sharetarget:sharetarget:1.2.0")
-    docs("androidx.slice:slice-builders:1.1.0-alpha02")
-    docs("androidx.slice:slice-builders-ktx:1.0.0-alpha08")
-    docs("androidx.slice:slice-core:1.1.0-alpha02")
-    docs("androidx.slice:slice-view:1.1.0-alpha02")
-    docs("androidx.slidingpanelayout:slidingpanelayout:1.2.0")
-    docs("androidx.sqlite:sqlite:2.4.0-alpha02")
-    docs("androidx.sqlite:sqlite-framework:2.4.0-alpha02")
-    docs("androidx.sqlite:sqlite-ktx:2.4.0-alpha02")
-    docs("androidx.startup:startup-runtime:1.2.0-alpha02")
-    docs("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
+    apiSinceDocs("androidx.paging:paging-testing:3.2.0")
+    apiSinceDocs("androidx.palette:palette:1.0.0")
+    apiSinceDocs("androidx.palette:palette-ktx:1.0.0")
+    apiSinceDocs("androidx.percentlayout:percentlayout:1.0.1")
+    apiSinceDocs("androidx.preference:preference:1.2.1")
+    apiSinceDocs("androidx.preference:preference-ktx:1.2.1")
+    apiSinceDocs("androidx.print:print:1.1.0-beta01")
+    apiSinceDocs("androidx.privacysandbox.ads:ads-adservices:1.1.0-alpha01")
+    apiSinceDocs("androidx.privacysandbox.ads:ads-adservices-java:1.1.0-alpha01")
+    apiSinceDocs("androidx.privacysandbox.sdkruntime:sdkruntime-client:1.0.0-alpha07")
+    apiSinceDocs("androidx.privacysandbox.sdkruntime:sdkruntime-core:1.0.0-alpha07")
+    apiSinceDocs("androidx.privacysandbox.tools:tools:1.0.0-alpha04")
+    apiSinceDocs("androidx.privacysandbox.ui:ui-client:1.0.0-alpha04")
+    apiSinceDocs("androidx.privacysandbox.ui:ui-core:1.0.0-alpha04")
+    apiSinceDocs("androidx.privacysandbox.ui:ui-provider:1.0.0-alpha04")
+    apiSinceDocs("androidx.profileinstaller:profileinstaller:1.3.1")
+    apiSinceDocs("androidx.recommendation:recommendation:1.0.0")
+    apiSinceDocs("androidx.recyclerview:recyclerview:1.3.1")
+    apiSinceDocs("androidx.recyclerview:recyclerview-selection:2.0.0-alpha01")
+    apiSinceDocs("androidx.remotecallback:remotecallback:1.0.0-alpha02")
+    apiSinceDocs("androidx.resourceinspection:resourceinspection-annotation:1.0.1")
+    apiSinceDocs("androidx.room:room-common:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-guava:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-ktx:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-migration:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-paging:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-paging-guava:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-paging-rxjava2:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-paging-rxjava3:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-runtime:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-rxjava2:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-rxjava3:2.6.0-alpha02")
+    apiSinceDocs("androidx.room:room-testing:2.6.0-alpha02")
+    apiSinceDocs("androidx.savedstate:savedstate:1.2.1")
+    apiSinceDocs("androidx.savedstate:savedstate-ktx:1.2.1")
+    apiSinceDocs("androidx.security:security-app-authenticator:1.0.0-alpha02")
+    apiSinceDocs("androidx.security:security-app-authenticator-testing:1.0.0-alpha01")
+    apiSinceDocs("androidx.security:security-crypto:1.1.0-alpha06")
+    apiSinceDocs("androidx.security:security-crypto-ktx:1.1.0-alpha06")
+    apiSinceDocs("androidx.security:security-identity-credential:1.0.0-alpha03")
+    apiSinceDocs("androidx.sharetarget:sharetarget:1.2.0")
+    apiSinceDocs("androidx.slice:slice-builders:1.1.0-alpha02")
+    apiSinceDocs("androidx.slice:slice-builders-ktx:1.0.0-alpha08")
+    apiSinceDocs("androidx.slice:slice-core:1.1.0-alpha02")
+    apiSinceDocs("androidx.slice:slice-view:1.1.0-alpha02")
+    apiSinceDocs("androidx.slidingpanelayout:slidingpanelayout:1.2.0")
+    apiSinceDocs("androidx.sqlite:sqlite:2.4.0-alpha02")
+    apiSinceDocs("androidx.sqlite:sqlite-framework:2.4.0-alpha02")
+    apiSinceDocs("androidx.sqlite:sqlite-ktx:2.4.0-alpha02")
+    apiSinceDocs("androidx.startup:startup-runtime:1.2.0-alpha02")
+    apiSinceDocs("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
+    // androidx.test is not hosted in androidx\
     docs("androidx.test:core:1.6.0-alpha01")
     docs("androidx.test:core-ktx:1.6.0-alpha01")
     docs("androidx.test:monitor:1.7.0-alpha01")
@@ -345,82 +352,84 @@
     docs("androidx.test.ext:truth:1.6.0-alpha01")
     docs("androidx.test.services:storage:1.5.0-alpha01")
     docs("androidx.test.uiautomator:uiautomator:2.3.0-alpha04")
+    // androidx.textclassifier is not hosted in androidx
     docs("androidx.textclassifier:textclassifier:1.0.0-alpha04")
-    docs("androidx.tracing:tracing:1.3.0-alpha02")
-    docs("androidx.tracing:tracing-ktx:1.3.0-alpha02")
-    docs("androidx.tracing:tracing-perfetto:1.0.0-beta01")
+    apiSinceDocs("androidx.tracing:tracing:1.3.0-alpha02")
+    apiSinceDocs("androidx.tracing:tracing-ktx:1.3.0-alpha02")
+    apiSinceDocs("androidx.tracing:tracing-perfetto:1.0.0-beta01")
     docs("androidx.tracing:tracing-perfetto-common:1.0.0-alpha16") // TODO(243405142) clean-up
-    docs("androidx.tracing:tracing-perfetto-handshake:1.0.0-beta01")
-    docs("androidx.transition:transition:1.5.0-alpha01")
-    docs("androidx.transition:transition-ktx:1.5.0-alpha01")
-    docs("androidx.tv:tv-foundation:1.0.0-alpha08")
-    docs("androidx.tv:tv-material:1.0.0-alpha08")
+    apiSinceDocs("androidx.tracing:tracing-perfetto-handshake:1.0.0-beta01")
+    apiSinceDocs("androidx.transition:transition:1.5.0-alpha01")
+    apiSinceDocs("androidx.transition:transition-ktx:1.5.0-alpha01")
+    apiSinceDocs("androidx.tv:tv-foundation:1.0.0-alpha08")
+    apiSinceDocs("androidx.tv:tv-material:1.0.0-alpha08")
     samples("androidx.tv:tv-samples:1.0.0-alpha08")
-    docs("androidx.tvprovider:tvprovider:1.1.0-alpha01")
-    docs("androidx.vectordrawable:vectordrawable:1.2.0-beta01")
-    docs("androidx.vectordrawable:vectordrawable-animated:1.2.0-alpha01")
-    docs("androidx.vectordrawable:vectordrawable-seekable:1.0.0-beta01")
-    docs("androidx.versionedparcelable:versionedparcelable:1.1.1")
-    docs("androidx.viewpager2:viewpager2:1.1.0-beta02")
-    docs("androidx.viewpager:viewpager:1.1.0-alpha01")
-    docs("androidx.wear.compose:compose-foundation:1.3.0-alpha02")
+    apiSinceDocs("androidx.tvprovider:tvprovider:1.1.0-alpha01")
+    apiSinceDocs("androidx.vectordrawable:vectordrawable:1.2.0-beta01")
+    apiSinceDocs("androidx.vectordrawable:vectordrawable-animated:1.2.0-alpha01")
+    apiSinceDocs("androidx.vectordrawable:vectordrawable-seekable:1.0.0-beta01")
+    apiSinceDocs("androidx.versionedparcelable:versionedparcelable:1.1.1")
+    apiSinceDocs("androidx.viewpager2:viewpager2:1.1.0-beta02")
+    apiSinceDocs("androidx.viewpager:viewpager:1.1.0-alpha01")
+    apiSinceDocs("androidx.wear.compose:compose-foundation:1.3.0-alpha02")
     samples("androidx.wear.compose:compose-foundation-samples:1.3.0-alpha02")
-    docs("androidx.wear.compose:compose-material:1.3.0-alpha02")
-    docs("androidx.wear.compose:compose-material-core:1.3.0-alpha02")
+    apiSinceDocs("androidx.wear.compose:compose-material:1.3.0-alpha02")
+    apiSinceDocs("androidx.wear.compose:compose-material-core:1.3.0-alpha02")
     samples("androidx.wear.compose:compose-material-samples:1.3.0-alpha02")
-    docs("androidx.wear.compose:compose-material3:1.0.0-alpha08")
+    apiSinceDocs("androidx.wear.compose:compose-material3:1.0.0-alpha08")
     samples("androidx.wear.compose:compose-material3-samples:1.3.0-alpha02")
-    docs("androidx.wear.compose:compose-navigation:1.3.0-alpha02")
+    apiSinceDocs("androidx.wear.compose:compose-navigation:1.3.0-alpha02")
     samples("androidx.wear.compose:compose-navigation-samples:1.3.0-alpha02")
-    docs("androidx.wear.compose:compose-ui-tooling:1.3.0-alpha02")
-    docs("androidx.wear.protolayout:protolayout:1.0.0-rc01")
-    docs("androidx.wear.protolayout:protolayout-expression:1.0.0-rc01")
-    docs("androidx.wear.protolayout:protolayout-material:1.0.0-rc01")
-    docs("androidx.wear.protolayout:protolayout-renderer:1.0.0-rc01")
-    docs("androidx.wear.tiles:tiles:1.2.0-rc01")
-    docs("androidx.wear.tiles:tiles-material:1.2.0-rc01")
-    docs("androidx.wear.tiles:tiles-renderer:1.2.0-rc01")
-    docs("androidx.wear.tiles:tiles-testing:1.2.0-rc01")
-    docs("androidx.wear.tiles:tiles-tooling:1.2.0-alpha07")
-    docs("androidx.wear.watchface:watchface:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-client:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-client-guava:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-complications:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-complications-data:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-complications-data-source:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-complications-data-source-ktx:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-complications-rendering:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-data:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-editor:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-editor-guava:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-guava:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.compose:compose-ui-tooling:1.3.0-alpha02")
+    apiSinceDocs("androidx.wear.protolayout:protolayout:1.0.0-rc01")
+    apiSinceDocs("androidx.wear.protolayout:protolayout-expression:1.0.0-rc01")
+    apiSinceDocs("androidx.wear.protolayout:protolayout-material:1.0.0-rc01")
+    apiSinceDocs("androidx.wear.protolayout:protolayout-renderer:1.0.0-rc01")
+    apiSinceDocs("androidx.wear.tiles:tiles:1.2.0-rc01")
+    apiSinceDocs("androidx.wear.tiles:tiles-material:1.2.0-rc01")
+    apiSinceDocs("androidx.wear.tiles:tiles-renderer:1.2.0-rc01")
+    apiSinceDocs("androidx.wear.tiles:tiles-testing:1.2.0-rc01")
+    apiSinceDocs("androidx.wear.tiles:tiles-tooling:1.2.0-alpha07")
+    apiSinceDocs("androidx.wear.watchface:watchface:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-client:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-client-guava:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-complications:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-complications-data:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-complications-data-source:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-complications-data-source-ktx:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-complications-rendering:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-data:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-editor:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-editor-guava:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-guava:1.2.0-alpha09")
     samples("androidx.wear.watchface:watchface-samples:1.2.0-alpha09")
-    docs("androidx.wear.watchface:watchface-style:1.2.0-alpha09")
+    apiSinceDocs("androidx.wear.watchface:watchface-style:1.2.0-alpha09")
+    // TODO(b/294531403): Turn on apiSince for wear when it releases as alpha
     docs("androidx.wear:wear:1.3.0-rc01")
     stubs(fileTree(dir: "../wear/wear_stubs/", include: ["com.google.android.wearable-stubs.jar"]))
-    docs("androidx.wear:wear-input:1.2.0-alpha02")
+    apiSinceDocs("androidx.wear:wear-input:1.2.0-alpha02")
     samples("androidx.wear:wear-input-samples:1.2.0-alpha01")
-    docs("androidx.wear:wear-input-testing:1.2.0-alpha02")
-    docs("androidx.wear:wear-ongoing:1.1.0-alpha01")
-    docs("androidx.wear:wear-phone-interactions:1.1.0-alpha03")
-    docs("androidx.wear:wear-remote-interactions:1.1.0-alpha01")
-    docs("androidx.webkit:webkit:1.8.0-beta01")
-    docs("androidx.window.extensions.core:core:1.0.0")
-    docs("androidx.window:window:1.2.0-beta01")
+    apiSinceDocs("androidx.wear:wear-input-testing:1.2.0-alpha02")
+    apiSinceDocs("androidx.wear:wear-ongoing:1.1.0-alpha01")
+    apiSinceDocs("androidx.wear:wear-phone-interactions:1.1.0-alpha03")
+    apiSinceDocs("androidx.wear:wear-remote-interactions:1.1.0-alpha01")
+    apiSinceDocs("androidx.webkit:webkit:1.8.0-beta01")
+    apiSinceDocs("androidx.window.extensions.core:core:1.0.0")
+    apiSinceDocs("androidx.window:window:1.2.0-beta01")
     stubs(fileTree(dir: "../window/stubs/", include: ["window-sidecar-release-0.1.0-alpha01.aar"]))
-    docs("androidx.window:window-core:1.2.0-beta01")
+    apiSinceDocs("androidx.window:window-core:1.2.0-beta01")
     stubs("androidx.window:window-extensions:1.0.0-alpha01")
-    docs("androidx.window:window-java:1.2.0-beta01")
-    docs("androidx.window:window-rxjava2:1.2.0-beta01")
-    docs("androidx.window:window-rxjava3:1.2.0-beta01")
+    apiSinceDocs("androidx.window:window-java:1.2.0-beta01")
+    apiSinceDocs("androidx.window:window-rxjava2:1.2.0-beta01")
+    apiSinceDocs("androidx.window:window-rxjava3:1.2.0-beta01")
     samples("androidx.window:window-samples:1.2.0-beta01")
-    docs("androidx.window:window-testing:1.2.0-beta01")
-    docs("androidx.work:work-gcm:2.9.0-alpha02")
-    docs("androidx.work:work-multiprocess:2.9.0-alpha02")
-    docs("androidx.work:work-runtime:2.9.0-alpha02")
-    docs("androidx.work:work-runtime-ktx:2.9.0-alpha02")
-    docs("androidx.work:work-rxjava2:2.9.0-alpha02")
-    docs("androidx.work:work-rxjava3:2.9.0-alpha02")
-    docs("androidx.work:work-testing:2.9.0-alpha02")
+    apiSinceDocs("androidx.window:window-testing:1.2.0-beta01")
+    apiSinceDocs("androidx.work:work-gcm:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-multiprocess:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-runtime:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-runtime-ktx:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-rxjava2:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-rxjava3:2.9.0-alpha02")
+    apiSinceDocs("androidx.work:work-testing:2.9.0-alpha02")
 }
 
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index ff30a309..716f6cd 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -193,6 +193,7 @@
     samples(project(":glance:glance-appwidget:glance-appwidget-samples"))
     docs(project(":glance:glance-appwidget-preview"))
     docs(project(":glance:glance-preview"))
+    docs(project(":glance:glance-testing"))
     docs(project(":glance:glance-wear-tiles"))
     docs(project(":glance:glance-wear-tiles-preview"))
     docs(project(":graphics:filters:filters"))
diff --git a/docs/macrobenchmarking.md b/docs/macrobenchmarking.md
index 86d0536..82b0e6f 100644
--- a/docs/macrobenchmarking.md
+++ b/docs/macrobenchmarking.md
@@ -6,11 +6,11 @@
 
 <table>
     <tr>
-      <td><strong>Macrobenchmark</strong> (new!)</td>
-      <td><strong>Benchmark</strong> (existing!)</td>
+      <td><strong>Macrobenchmark</strong></td>
+      <td><strong>Benchmark</strong></td>
     </tr>
     <tr>
-        <td>Measure high-level entry points (Activity launch / Scrolling a list)</td>
+        <td>Measure high-level entry points(Activity launch / Scrolling a list)</td>
         <td>Measure individual functions</td>
     </tr>
     <tr>
@@ -22,8 +22,8 @@
         <td>Fast iteration speed (Often less than 10 seconds)</td>
     </tr>
     <tr>
-        <td>Results come with profiling traces</td>
-        <td>Optional stack sampling/method tracing</td>
+        <td>Configure compilation with CompilationMode</td>
+        <td>Always fully AOT (<code>speed</code>) compiled.</td>
     </tr>
     <tr>
         <td>Min API 23</td>
@@ -57,7 +57,7 @@
         packageName = "mypackage.myapp",
         metrics = listOf(StartupTimingMetric()),
         startupMode = StartupMode.COLD,
-        iterations = 5
+        iterations = 10
     ) { // this = MacrobenchmarkScope
         pressHome()
         val intent = Intent()
@@ -79,7 +79,7 @@
                 "mypackage.myapp",
                 Collections.singletonList(new StartupTimingMetric()),
                 StartupMode.COLD,
-                /* iterations = */ 5,
+                /* iterations = */ 10,
                 scope -> {
                     scope.pressHome();
                     Intent intent = Intent();
@@ -118,7 +118,7 @@
 
 *   [`:compose:integration-tests:macrobenchmark`](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/integration-tests/macrobenchmark/)
 
-*   [AffectedModuleDetector Entry](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt;l=526;drc=cfb504756386b6225a2176d1d6efe2f55d4fa564)
+*   [AffectedModuleDetector Entry](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/private/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt;l=580;drc=10e32d25e9859bb5b37c303f42731015c82ee982)
 
 Note: Compose macrobenchmarks are generally duplicated with View system
 counterparts, defined in `:benchmark:integration-tests:macrobenchmark-target`.
@@ -132,17 +132,17 @@
       <td><strong>Required setup</strong></td>
     </tr>
     <tr>
-        <td>Two modules in settings.gradle</td>
+        <td>Two modules in <code>settings.gradle</code></td>
         <td>Both the macrobenchmark and target must be defined in sibling
           modules</td>
     </tr>
     <tr>
         <td>The module name for the benchmark (test) module</td>
-        <td>It must match /.*:integration-tests:.*macrobenchmark/</td>
+        <td>It must match <code>/.*:integration-tests:.*macrobenchmark/</code></td>
     </tr>
     <tr>
         <td>The module name for the target (integration app) module</td>
-        <td>It must match /.*:integration-tests:.*macrobenchmark-target</td>
+        <td>It must match <code>/.*:integration-tests:.*macrobenchmark-target</code></td>
     </tr>
     <tr>
         <td>Register the modules in AffectedModuleDetector.kt</td>
diff --git a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimationTest.kt b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimationTest.kt
index 2189434..e139fc6 100644
--- a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimationTest.kt
+++ b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimationTest.kt
@@ -17,14 +17,18 @@
 
 import android.app.Instrumentation
 import android.graphics.Canvas
+import android.os.Build
 import android.os.Bundle
 import android.os.SystemClock
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.view.animation.Animation
+import android.view.animation.Animation.AnimationListener
 import android.view.animation.AnimationUtils
 import android.view.animation.TranslateAnimation
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
 import androidx.annotation.AnimRes
 import androidx.annotation.LayoutRes
 import androidx.core.view.ViewCompat
@@ -35,6 +39,7 @@
 import androidx.test.core.app.ActivityScenario
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.testutils.waitForExecution
 import androidx.testutils.withActivity
@@ -241,6 +246,10 @@
             .commit()
         activityRule.executePendingTransactions()
 
+        assertThat(
+            parent.animationStartedCountDownLatch.await(1000, TimeUnit.MILLISECONDS)
+        ).isTrue()
+
         assertFragmentAnimation(parent, 1, true, ENTER)
 
         val child = AnimationFragment()
@@ -259,6 +268,10 @@
         activityRule.executePendingTransactions()
 
         assertThat(childContainer.findViewById<View>(childView.id)).isNotNull()
+        assertThat(
+            parent.animationStartedCountDownLatch.await(1000, TimeUnit.MILLISECONDS)
+        ).isTrue()
+
         assertFragmentAnimation(parent, 2, false, EXIT)
     }
 
@@ -275,6 +288,10 @@
             .commit()
         activityRule.executePendingTransactions()
 
+        assertThat(
+            parent.animationStartedCountDownLatch.await(1000, TimeUnit.MILLISECONDS)
+        ).isTrue()
+
         assertFragmentAnimation(parent, 1, true, ENTER)
 
         val child = AnimationFragment(R.layout.simple_container)
@@ -303,6 +320,10 @@
 
         assertThat(childContainer.findViewById<View>(childView.id)).isNotNull()
         assertThat(grandChildContainer.findViewById<View>(grandChildView.id)).isNotNull()
+        assertThat(
+            parent.animationStartedCountDownLatch.await(1000, TimeUnit.MILLISECONDS)
+        ).isTrue()
+
         assertFragmentAnimation(parent, 2, false, EXIT)
     }
 
@@ -403,7 +424,7 @@
             .setReorderingAllowed(true)
             .commit()
         activityRule.waitForExecution()
-        assertThat(fragment1.numAnimators).isEqualTo(0)
+        assertThat(fragment1.numStartedAnimators).isEqualTo(0)
 
         val fragment2 = AnimationFragment()
         fragment2.postponeEnterTransition()
@@ -431,10 +452,8 @@
         assertThat(fragment2.view).isNull()
         assertThat(fragment2.isAdded).isFalse()
 
-        assertThat(fragment1.numAnimators).isEqualTo(0)
-        assertThat(fragment2.numAnimators).isEqualTo(0)
-        assertThat(fragment1.animation).isNull()
-        assertThat(fragment2.animation).isNull()
+        assertThat(fragment1.numStartedAnimators).isEqualTo(0)
+        assertThat(fragment2.numStartedAnimators).isEqualTo(0)
     }
 
     // Make sure that if the state was saved while a Fragment was animating that its
@@ -618,6 +637,69 @@
         assertThat(fragment1.requireView().parent).isNotNull()
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun replaceOperationWithAnimationsThenSystemBack() {
+        waitForAnimationReady()
+        val fm1 = activityRule.activity.supportFragmentManager
+
+        val fragment1 = AnimationListenerFragment(R.layout.scene1)
+        fm1.beginTransaction()
+            .replace(R.id.fragmentContainer, fragment1, "1")
+            .addToBackStack(null)
+            .commit()
+        activityRule.waitForExecution()
+
+        val fragment2 = AnimationListenerFragment()
+
+        fm1.beginTransaction()
+            .setCustomAnimations(
+                R.anim.fade_in,
+                R.anim.fade_out,
+                R.anim.fade_in,
+                R.anim.fade_out
+            )
+            .replace(R.id.fragmentContainer, fragment2, "2")
+            .addToBackStack(null)
+            .commit()
+        activityRule.executePendingTransactions(fm1)
+
+        assertThat(fragment2.startAnimationLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+        // We need to wait for the exit animation to end
+        assertThat(fragment1.exitLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        val dispatcher = activityRule.activity.onBackPressedDispatcher
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackStarted(BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT))
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        // We should not start any animations when we get the started callback
+        assertThat(fragment1.enterStartCount).isEqualTo(0)
+
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackProgressed(
+                BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+            )
+            dispatcher.onBackPressed()
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        assertThat(fragment2.startAnimationLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+        // Now fragment2 should be animating away
+        assertThat(fragment2.isAdded).isFalse()
+        assertThat(fm1.findFragmentByTag("2"))
+            .isEqualTo(null) // fragmentManager does not know about animating fragment
+        assertThat(fragment2.parentFragmentManager)
+            .isEqualTo(fm1) // but the animating fragment knows the fragmentManager
+
+        // We need to wait for the exit animation to end
+        assertThat(fragment2.exitLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        // Make sure the original fragment was correctly readded to the container
+        assertThat(fragment1.requireView().parent).isNotNull()
+    }
+
     // When an animation is running on a Fragment's View, the view shouldn't be
     // prevented from being removed. There's no way to directly test this, so we have to
     // test to see if the animation is still running.
@@ -1027,7 +1109,7 @@
         isEnter: Boolean,
         animatorResourceId: Int
     ) {
-        assertThat(fragment.numAnimators).isEqualTo(numAnimators)
+        assertThat(fragment.numStartedAnimators).isEqualTo(numAnimators)
         assertThat(fragment.enter).isEqualTo(isEnter)
         assertThat(fragment.resourceId).isEqualTo(animatorResourceId)
         assertThat(fragment.animation).isNotNull()
@@ -1043,7 +1125,7 @@
         assertThat(fragment.onCreateViewCalled).isTrue()
         assertThat(fragment.requireView().visibility).isEqualTo(View.VISIBLE)
         assertThat(fragment.requireView().alpha).isWithin(0f).of(0f)
-        assertThat(fragment.numAnimators).isEqualTo(expectedAnimators)
+        assertThat(fragment.numStartedAnimators).isEqualTo(expectedAnimators)
     }
 
     // On Lollipop and earlier, animations are not allowed during window transitions
@@ -1078,7 +1160,8 @@
 
     class AnimationFragment(@LayoutRes contentLayoutId: Int = R.layout.strict_view_fragment) :
         StrictViewFragment(contentLayoutId) {
-        var numAnimators: Int = 0
+        var numStartedAnimators: Int = 0
+        var animationStartedCountDownLatch = CountDownLatch(1)
         var animation: Animation? = null
         var enter: Boolean = false
         var resourceId: Int = 0
@@ -1092,9 +1175,19 @@
                 return null
             }
             loadedAnimation = nextAnim
-            numAnimators++
             animation = TranslateAnimation(-10f, 0f, 0f, 0f)
             (animation as TranslateAnimation).duration = 1
+            animationStartedCountDownLatch = CountDownLatch(1)
+            (animation as TranslateAnimation).setAnimationListener(object : AnimationListener {
+                override fun onAnimationStart(p0: Animation?) {
+                    numStartedAnimators++
+                    animationStartedCountDownLatch.countDown()
+                }
+
+                override fun onAnimationEnd(p0: Animation?) { }
+
+                override fun onAnimationRepeat(p0: Animation?) { }
+            })
             resourceId = nextAnim
             this.enter = enter
             return animation
diff --git a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimatorTest.kt b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimatorTest.kt
index 88950b5..ab3b7d6 100644
--- a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimatorTest.kt
+++ b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentAnimatorTest.kt
@@ -24,6 +24,8 @@
 import android.os.Build
 import android.view.Choreographer
 import android.view.View
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
 import androidx.annotation.AnimatorRes
 import androidx.annotation.LayoutRes
 import androidx.annotation.RequiresApi
@@ -34,6 +36,7 @@
 import androidx.test.annotation.UiThreadTest
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
 import androidx.testutils.waitForExecution
 import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.CountDownLatch
@@ -479,7 +482,7 @@
             .setReorderingAllowed(true)
             .commit()
         activityRule.waitForExecution()
-        assertThat(fragment1.numAnimators).isEqualTo(0)
+        assertThat(fragment1.numStartedAnimators).isEqualTo(0)
 
         val fragment2 = AnimatorFragment()
         fragment2.postponeEnterTransition()
@@ -506,11 +509,8 @@
         assertThat(fragment2.view).isNull()
         assertThat(fragment2.isAdded).isFalse()
 
-        assertThat(fragment1.numAnimators).isEqualTo(0)
-        assertThat(fragment2.numAnimators).isEqualTo(0)
-
-        assertThat(fragment1.initialized).isFalse()
-        assertThat(fragment2.initialized).isFalse()
+        assertThat(fragment1.numStartedAnimators).isEqualTo(0)
+        assertThat(fragment2.numStartedAnimators).isEqualTo(0)
     }
 
     // Make sure that if the state was saved while a Fragment was animating that its
@@ -654,6 +654,77 @@
         assertThat(fm1.findFragmentByTag("2")).isEqualTo(fragment2)
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun replaceOperationWithAnimatorsThenSystemBack() {
+        val fm1 = activityRule.activity.supportFragmentManager
+
+        val fragment1 = AnimatorFragment(R.layout.scene1)
+        fm1.beginTransaction()
+            .replace(R.id.fragmentContainer, fragment1, "1")
+            .addToBackStack(null)
+            .commit()
+        activityRule.waitForExecution()
+
+        val fragment2 = AnimatorFragment()
+
+        fm1.beginTransaction()
+            .setCustomAnimations(
+                android.R.animator.fade_in,
+                android.R.animator.fade_out,
+                android.R.animator.fade_in,
+                android.R.animator.fade_out
+            )
+            .replace(R.id.fragmentContainer, fragment2, "2")
+            .addToBackStack(null)
+            .commit()
+        activityRule.executePendingTransactions(fm1)
+
+        assertThat(fragment2.wasStarted).isTrue()
+        // We need to wait for the exit animation to end
+        assertThat(fragment1.endLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        val dispatcher = activityRule.activity.onBackPressedDispatcher
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackStarted(BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT))
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackProgressed(
+                BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+            )
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        if (FragmentManager.USE_PREDICTIVE_BACK) {
+            assertThat(fragment1.startLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+            assertThat(fragment2.startLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+            assertThat(fragment1.inProgress).isTrue()
+            assertThat(fragment2.inProgress).isTrue()
+        } else {
+            assertThat(fragment1.inProgress).isFalse()
+            assertThat(fragment1.inProgress).isFalse()
+        }
+
+        activityRule.runOnUiThread {
+            dispatcher.onBackPressed()
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        assertThat(fragment2.wasStarted).isTrue()
+        // Now fragment2 should be animating away
+        assertThat(fragment2.isAdded).isFalse()
+        assertThat(fm1.findFragmentByTag("2"))
+            .isEqualTo(null) // fragmentManager does not know about animating fragment
+
+        // We need to wait for the exit animation to end
+        assertThat(fragment2.endLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        // Make sure the original fragment was correctly readded to the container
+        assertThat(fragment1.requireView().parent).isNotNull()
+    }
+
     private fun assertEnterPopExit(fragment: AnimatorFragment) {
         assertFragmentAnimation(fragment, 1, true, ENTER)
 
@@ -697,7 +768,7 @@
         isEnter: Boolean,
         animatorResourceId: Int
     ) {
-        assertThat(fragment.numAnimators).isEqualTo(numAnimators)
+        assertThat(fragment.numStartedAnimators).isEqualTo(numAnimators)
         assertThat(fragment.baseEnter).isEqualTo(isEnter)
         assertThat(fragment.resourceId).isEqualTo(animatorResourceId)
         assertThat(fragment.baseAnimator).isNotNull()
@@ -709,19 +780,21 @@
         assertThat(fragment.onCreateViewCalled).isTrue()
         assertThat(fragment.requireView().visibility).isEqualTo(View.VISIBLE)
         assertThat(fragment.requireView().alpha).isWithin(0f).of(0f)
-        assertThat(fragment.numAnimators).isEqualTo(expectedAnimators)
+        assertThat(fragment.numStartedAnimators).isEqualTo(expectedAnimators)
     }
 
     class AnimatorFragment(@LayoutRes contentLayoutId: Int = R.layout.strict_view_fragment) :
         StrictViewFragment(contentLayoutId) {
-        var numAnimators: Int = 0
+        var numStartedAnimators: Int = 0
         lateinit var baseAnimator: Animator
         var baseEnter: Boolean = false
         var resourceId: Int = 0
         var wasStarted: Boolean = false
+        lateinit var startLatch: CountDownLatch
         lateinit var endLatch: CountDownLatch
         var resumeLatch = CountDownLatch(1)
         var initialized: Boolean = false
+        var inProgress = false
 
         override fun onCreateAnimator(
             transit: Int,
@@ -745,14 +818,18 @@
                 addListener(object : AnimatorListenerAdapter() {
                     override fun onAnimationStart(animation: Animator) {
                         wasStarted = true
+                        inProgress = true
+                        numStartedAnimators++
+                        startLatch.countDown()
                     }
 
                     override fun onAnimationEnd(animation: Animator) {
                         endLatch.countDown()
+                        inProgress = false
                     }
                 })
-                numAnimators++
                 wasStarted = false
+                startLatch = CountDownLatch(1)
                 endLatch = CountDownLatch(1)
                 resourceId = nextAnim
                 baseEnter = enter
diff --git a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionAnimTest.kt b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionAnimTest.kt
index 4e287f7..91055a8 100644
--- a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionAnimTest.kt
+++ b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionAnimTest.kt
@@ -24,6 +24,8 @@
 import android.view.View
 import android.view.animation.Animation
 import android.view.animation.AnimationUtils
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
 import androidx.annotation.AnimRes
 import androidx.fragment.test.R
 import androidx.test.core.app.ActivityScenario
@@ -291,6 +293,68 @@
         }
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun replaceOperationWithTransitionsAndAnimatorsThenSystemBack() {
+        withUse(ActivityScenario.launch(SimpleContainerActivity::class.java)) {
+            val fm1 = withActivity { supportFragmentManager }
+
+            val fragment1 = TransitionAnimatorFragment()
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment1, "1")
+                .addToBackStack(null)
+                .commit()
+            executePendingTransactions()
+
+            val fragment2 = TransitionAnimatorFragment()
+
+            fm1.beginTransaction()
+                .setCustomAnimations(
+                    android.R.animator.fade_in,
+                    android.R.animator.fade_out,
+                    android.R.animator.fade_in,
+                    android.R.animator.fade_out
+                )
+                .replace(R.id.fragmentContainer, fragment2, "2")
+                .addToBackStack(null)
+                .commit()
+            executePendingTransactions()
+
+            assertThat(fragment2.startTransitionCountDownLatch.await(1000, TimeUnit.MILLISECONDS))
+                .isTrue()
+            // We need to wait for the exit animation to end
+            assertThat(
+                fragment1.endTransitionCountDownLatch.await(
+                    1000,
+                    TimeUnit.MILLISECONDS
+                )
+            ).isTrue()
+
+            val dispatcher = withActivity { onBackPressedDispatcher }
+            dispatcher.dispatchOnBackStarted(BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT))
+            executePendingTransactions()
+
+            // We should not start any transitions when we get the started callback
+            fragment1.waitForNoTransition()
+
+            dispatcher.dispatchOnBackProgressed(
+                BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+            )
+            dispatcher.onBackPressed()
+            executePendingTransactions()
+
+            fragment1.waitForTransition()
+            fragment2.waitForTransition()
+
+            assertThat(fragment2.isAdded).isFalse()
+            assertThat(fm1.findFragmentByTag("2"))
+                .isEqualTo(null)
+
+            // Make sure the original fragment was correctly readded to the container
+            assertThat(fragment1.requireView().parent).isNotNull()
+        }
+    }
+
     class TransitionAnimationFragment : TransitionFragment(R.layout.scene1) {
         val startAnimationLatch = CountDownLatch(1)
         val exitAnimationLatch = CountDownLatch(1)
diff --git a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionTest.kt b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionTest.kt
index 2f174b5..31e9b75 100644
--- a/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionTest.kt
+++ b/fragment/fragment/src/androidTest/java/androidx/fragment/app/FragmentTransitionTest.kt
@@ -21,6 +21,8 @@
 import android.transition.TransitionSet
 import android.view.View
 import android.widget.TextView
+import android.window.BackEvent
+import androidx.activity.BackEventCompat
 import androidx.annotation.LayoutRes
 import androidx.annotation.RequiresApi
 import androidx.core.app.SharedElementCallback
@@ -1343,6 +1345,60 @@
         }
     }
 
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun replaceOperationWithTransitionsThenSystemBack() {
+        val fm1 = activityRule.activity.supportFragmentManager
+
+        val fragment1 = TransitionFragment(R.layout.scene1)
+        fm1.beginTransaction()
+            .replace(R.id.fragmentContainer, fragment1, "1")
+            .addToBackStack(null)
+            .commit()
+        activityRule.waitForExecution()
+
+        val fragment2 = TransitionFragment()
+
+        fm1.beginTransaction()
+            .replace(R.id.fragmentContainer, fragment2, "2")
+            .addToBackStack(null)
+            .commit()
+        activityRule.executePendingTransactions(fm1)
+
+        assertThat(fragment2.startTransitionCountDownLatch.await(1000, TimeUnit.MILLISECONDS))
+            .isTrue()
+        // We need to wait for the exit animation to end
+        assertThat(fragment1.endTransitionCountDownLatch.await(1000, TimeUnit.MILLISECONDS))
+            .isTrue()
+
+        val dispatcher = activityRule.activity.onBackPressedDispatcher
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackStarted(BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT))
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        // We should not start any transitions when we get the started callback
+        fragment1.waitForNoTransition()
+
+        activityRule.runOnUiThread {
+            dispatcher.dispatchOnBackProgressed(
+                BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+            )
+            dispatcher.onBackPressed()
+        }
+        activityRule.executePendingTransactions(fm1)
+
+        fragment1.waitForTransition()
+        fragment2.waitForTransition()
+
+        assertThat(fragment2.isAdded).isFalse()
+        assertThat(fm1.findFragmentByTag("2"))
+            .isEqualTo(null)
+
+        // Make sure the original fragment was correctly readded to the container
+        assertThat(fragment1.requireView().parent).isNotNull()
+    }
+
     private fun setupInitialFragment(): TransitionFragment {
         val fragmentManager = activityRule.activity.supportFragmentManager
         val fragment1 = TransitionFragment(R.layout.scene1)
diff --git a/fragment/fragment/src/androidTest/java/androidx/fragment/app/SpecialEffectsControllerTest.kt b/fragment/fragment/src/androidTest/java/androidx/fragment/app/SpecialEffectsControllerTest.kt
index 6d90a78..b674e61 100644
--- a/fragment/fragment/src/androidTest/java/androidx/fragment/app/SpecialEffectsControllerTest.kt
+++ b/fragment/fragment/src/androidTest/java/androidx/fragment/app/SpecialEffectsControllerTest.kt
@@ -560,7 +560,9 @@
 ) : SpecialEffectsController(container) {
     var executeOperationsCallCount = 0
 
-    override fun collectEffects(operations: List<Operation>, isPop: Boolean) {
+    override fun collectEffects(operations: List<Operation>, isPop: Boolean) { }
+
+    override fun commitEffects(operations: List<Operation>) {
         executeOperationsCallCount++
         operations.forEach(Operation::complete)
     }
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 738d4de..e1cb1f5 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
@@ -26,6 +26,7 @@
 import android.view.View
 import android.view.ViewGroup
 import android.view.animation.Animation
+import androidx.activity.BackEventCompat
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.collection.ArrayMap
@@ -101,53 +102,6 @@
         collectAnimEffects(animations, startedAnyTransition, startedTransitions)
     }
 
-    override fun commitEffects(operations: List<Operation>) {
-        val firstOut = operations.firstOrNull { operation ->
-            val currentState = operation.fragment.mView.asOperationState()
-            // The firstOut Operation is the first Operation moving from VISIBLE
-            currentState == Operation.State.VISIBLE &&
-                operation.finalState != Operation.State.VISIBLE
-        }
-        val lastIn = operations.lastOrNull { operation ->
-            val currentState = operation.fragment.mView.asOperationState()
-            // The last Operation that moves to VISIBLE is the lastIn Operation
-            currentState != Operation.State.VISIBLE &&
-                operation.finalState == Operation.State.VISIBLE
-        }
-        // Run the transition Effects
-        operations.first().transitionEffect?.apply {
-            onStart(container)
-            onCommit(container)
-        }
-
-        // Run all of the Animation, Animator, and NoOp Effects we have collected
-        for (i in operations.indices) {
-            val operation = operations[i]
-            for (j in operation.effects.indices) {
-                val effect = operation.effects[j]
-                effect.onStart(container)
-                effect.onCommit(container)
-            }
-        }
-
-        for (i in operations.indices) {
-            val operation = operations[i]
-            applyContainerChangesToOperation(operation)
-        }
-
-        if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
-            Log.v(FragmentManager.TAG,
-                "Completed executing operations from $firstOut to $lastIn")
-        }
-    }
-
-    internal fun applyContainerChangesToOperation(operation: Operation) {
-        if (operation.isAwaitingContainerChanges) {
-            applyContainerChanges(operation)
-            operation.isAwaitingContainerChanges = false
-        }
-    }
-
     /**
      * Syncs the animations of all other operations with the animations of the last operation.
      */
@@ -177,11 +131,6 @@
         for (animatorInfo: AnimationInfo in animationInfos) {
             val context = container.context
             val operation: Operation = animatorInfo.operation
-            if (animatorInfo.isVisibilityUnchanged) {
-                // No change in visibility, so we can immediately complete the animation
-                operation.effects.add(NoOpEffect(animatorInfo))
-                continue
-            }
             val anim = animatorInfo.getAnimation(context)
             if (anim == null) {
                 // No Animator or Animation, so we can immediately complete the animation
@@ -280,19 +229,15 @@
             return startedTransitions
         }
 
-        // Every transition needs to target at least one View so that they
-        // don't interfere with one another. This is the view we use
-        // in cases where there are no real views to target
-        val nonExistentView = View(container.context)
-
         // Now find the shared element transition if it exists
         var sharedElementTransition: Any? = null
-        var firstOutEpicenterView: View? = null
-        var hasLastInEpicenter = false
-        val lastInEpicenterRect = Rect()
         val sharedElementFirstOutViews = ArrayList<View>()
         val sharedElementLastInViews = ArrayList<View>()
         val sharedElementNameMapping = ArrayMap<String, String>()
+        var enteringNames = ArrayList<String>()
+        var exitingNames = ArrayList<String>()
+        val firstOutViews = ArrayMap<String, View>()
+        val lastInViews = ArrayMap<String, View>()
         for (transitionInfo: TransitionInfo in transitionInfos) {
             val hasSharedElementTransition = transitionInfo.hasSharedElementTransition()
             // Compute the shared element transition between the firstOut and lastIn Fragments
@@ -302,7 +247,7 @@
                     transitionImpl.cloneTransition(transitionInfo.sharedElementTransition))
                 // The exiting shared elements default to the source names from the
                 // last in fragment
-                val exitingNames = lastIn.fragment.sharedElementSourceNames
+                exitingNames = lastIn.fragment.sharedElementSourceNames
                 // But if we're doing multiple transactions, we may need to re-map
                 // the names from the first out fragment
                 val firstOutSourceNames = firstOut.fragment.sharedElementSourceNames
@@ -317,7 +262,7 @@
                         exitingNames[nameIndex] = firstOutSourceNames[index]
                     }
                 }
-                val enteringNames = lastIn.fragment.sharedElementTargetNames
+                enteringNames = lastIn.fragment.sharedElementTargetNames
                 val (exitingCallback, enteringCallback) = if (!isPop) {
                     // Forward transitions have firstOut fragment exiting and the
                     // lastIn fragment entering
@@ -348,7 +293,6 @@
 
                 // Find all of the Views from the firstOut fragment that are
                 // part of the shared element transition
-                val firstOutViews = ArrayMap<String, View>()
                 findNamedViews(firstOutViews, firstOut.fragment.mView)
                 firstOutViews.retainAll(exitingNames)
                 if (exitingCallback != null) {
@@ -376,7 +320,6 @@
 
                 // Find all of the Views from the lastIn fragment that are
                 // part of the shared element transition
-                val lastInViews = ArrayMap<String, View>()
                 findNamedViews(lastInViews, lastIn.fragment.mView)
                 lastInViews.retainAll(enteringNames)
                 lastInViews.retainAll(sharedElementNameMapping.values)
@@ -419,168 +362,20 @@
                     sharedElementTransition = null
                     sharedElementFirstOutViews.clear()
                     sharedElementLastInViews.clear()
-                } else {
-                    // Call through to onSharedElementStart() before capturing the
-                    // starting values for the shared element transition
-                    callSharedElementStartEnd(lastIn.fragment, firstOut.fragment, isPop,
-                        firstOutViews, true)
-                    // Trigger the onSharedElementEnd callback in the next frame after
-                    // the starting values are captured and before capturing the end states
-                    OneShotPreDrawListener.add(container) {
-                        callSharedElementStartEnd(lastIn.fragment, firstOut.fragment, isPop,
-                            lastInViews, false)
-                    }
-                    sharedElementFirstOutViews.addAll(firstOutViews.values)
-
-                    // Compute the epicenter of the firstOut transition
-                    if (exitingNames.isNotEmpty()) {
-                        val epicenterViewName = exitingNames[0]
-                        firstOutEpicenterView = firstOutViews[epicenterViewName]
-                        transitionImpl.setEpicenter(
-                            sharedElementTransition, firstOutEpicenterView
-                        )
-                    }
-                    sharedElementLastInViews.addAll(lastInViews.values)
-
-                    // Compute the epicenter of the lastIn transition
-                    if (enteringNames.isNotEmpty()) {
-                        val epicenterViewName = enteringNames[0]
-                        val lastInEpicenterView = lastInViews[epicenterViewName]
-                        if (lastInEpicenterView != null) {
-                            hasLastInEpicenter = true
-                            // We can't set the epicenter here directly since the View might
-                            // not have been laid out as of yet, so instead we set a Rect as
-                            // the epicenter and compute the bounds one frame later
-                            val impl: FragmentTransitionImpl = transitionImpl
-                            OneShotPreDrawListener.add(container) {
-                                impl.getBoundsOnScreen(lastInEpicenterView, lastInEpicenterRect)
-                            }
-                        }
-                    }
-
-                    // Now set the transition's targets to only the firstOut Fragment's views
-                    // It'll be swapped to the lastIn Fragment's views after the
-                    // transition is started
-                    transitionImpl.setSharedElementTargets(sharedElementTransition,
-                        nonExistentView, sharedElementFirstOutViews)
-                    // After the swap to the lastIn Fragment's view (done below), we
-                    // need to clean up those targets. We schedule this here so that it
-                    // runs directly after the swap
-                    transitionImpl.scheduleRemoveTargets(sharedElementTransition, null, null,
-                        null, null, sharedElementTransition, sharedElementLastInViews)
-                    // Both the firstOut and lastIn Operations are now associated
-                    // with a Transition
-                    startedTransitions[firstOut] = true
-                    startedTransitions[lastIn] = true
-                }
-            }
-        }
-        val enteringViews = ArrayList<View>()
-        // These transitions run together, overlapping one another
-        var mergedTransition: Any? = null
-        // These transitions run only after all of the other transitions complete
-        var mergedNonOverlappingTransition: Any? = null
-        // Now iterate through the set of transitions and merge them together
-        for (transitionInfo: TransitionInfo in transitionInfos) {
-            val operation: Operation = transitionInfo.operation
-            if (transitionInfo.isVisibilityUnchanged) {
-                // No change in visibility, so we can immediately complete the transition
-                startedTransitions[transitionInfo.operation] = false
-                operation.effects.add(NoOpEffect(transitionInfo))
-                continue
-            }
-            val transition = transitionImpl.cloneTransition(transitionInfo.transition)
-            val involvedInSharedElementTransition = (sharedElementTransition != null &&
-                (operation === firstOut || operation === lastIn))
-            if (transition == null) {
-                // Nothing more to do if the transition is null
-                if (!involvedInSharedElementTransition) {
-                    // Only complete the transition if this fragment isn't involved
-                    // in the shared element transition (as otherwise we need to wait
-                    // for that to finish)
-                    startedTransitions[operation] = false
-                    operation.effects.add(NoOpEffect(transitionInfo))
-                }
-            } else {
-                // Target the Transition to *only* the set of transitioning views
-                val transitioningViews = ArrayList<View>()
-                captureTransitioningViews(transitioningViews, operation.fragment.mView)
-                if (involvedInSharedElementTransition) {
-                    // Remove all of the shared element views from the transition
-                    if (operation === firstOut) {
-                        transitioningViews.removeAll(sharedElementFirstOutViews.toSet())
-                    } else {
-                        transitioningViews.removeAll(sharedElementLastInViews.toSet())
-                    }
-                }
-                if (transitioningViews.isEmpty()) {
-                    transitionImpl.addTarget(transition, nonExistentView)
-                } else {
-                    transitionImpl.addTargets(transition, transitioningViews)
-                    transitionImpl.scheduleRemoveTargets(transition, transition,
-                        transitioningViews, null, null, null, null)
-                    if (operation.finalState === Operation.State.GONE) {
-                        // We're hiding the Fragment. This requires a bit of extra work
-                        // First, we need to avoid immediately applying the container change as
-                        // that will stop the Transition from occurring.
-                        operation.isAwaitingContainerChanges = false
-                        // Then schedule the actual hide of the fragment's view,
-                        // essentially doing what applyState() would do for us
-                        val transitioningViewsToHide = ArrayList(transitioningViews)
-                        transitioningViewsToHide.remove(operation.fragment.mView)
-                        transitionImpl.scheduleHideFragmentView(transition,
-                            operation.fragment.mView, transitioningViewsToHide)
-                        // This OneShotPreDrawListener gets fired before the delayed start of
-                        // the Transition and changes the visibility of any exiting child views
-                        // that *ARE NOT* shared element transitions. The TransitionManager then
-                        // properly considers exiting views and marks them as disappearing,
-                        // applying a transition and a listener to take proper actions once the
-                        // transition is complete.
-                        OneShotPreDrawListener.add(container) {
-                            setViewVisibility(transitioningViews, View.INVISIBLE)
-                        }
-                    }
-                }
-                if (operation.finalState === Operation.State.VISIBLE) {
-                    enteringViews.addAll(transitioningViews)
-                    if (hasLastInEpicenter) {
-                        transitionImpl.setEpicenter(transition, lastInEpicenterRect)
-                    }
-                } else {
-                    transitionImpl.setEpicenter(transition, firstOutEpicenterView)
-                }
-                startedTransitions[operation] = true
-                // Now determine how this transition should be merged together
-                if (transitionInfo.isOverlapAllowed) {
-                    // Overlap is allowed, so add them to the mergeTransition set
-                    mergedTransition = transitionImpl.mergeTransitionsTogether(
-                        mergedTransition, transition, null)
-                } else {
-                    // Overlap is not allowed, add them to the mergedNonOverlappingTransition
-                    mergedNonOverlappingTransition = transitionImpl.mergeTransitionsTogether(
-                        mergedNonOverlappingTransition, transition, null)
                 }
             }
         }
 
-        // Make sure that the mergedNonOverlappingTransition set
-        // runs after the mergedTransition set is complete
-        mergedTransition = transitionImpl.mergeTransitionsInSequence(mergedTransition,
-            mergedNonOverlappingTransition, sharedElementTransition)
-
-        // If there's no transitions playing together, no non-overlapping transitions,
-        // and no shared element transitions, mergedTransition will be null and
-        // there's nothing else we need to do
-        if (mergedTransition == null) {
-            return startedTransitions
-        }
-
-        transitionInfos.first().operation.transitionEffect = TransitionEffect(
-            transitionInfos, firstOut, lastIn, transitionImpl, mergedTransition,
-            enteringViews, sharedElementTransition, sharedElementFirstOutViews,
-            sharedElementLastInViews, sharedElementNameMapping
+        val transitionEffect = TransitionEffect(
+            transitionInfos, firstOut, lastIn, transitionImpl, sharedElementTransition,
+            sharedElementFirstOutViews, sharedElementLastInViews, sharedElementNameMapping,
+            enteringNames, exitingNames, firstOutViews, lastInViews, isPop, startedTransitions
         )
 
+        transitionInfos.forEach { transitionInfo ->
+            transitionInfo.operation.effects.add(transitionEffect)
+        }
+
         return startedTransitions
     }
 
@@ -594,36 +389,6 @@
     }
 
     /**
-     * Gets the Views in the hierarchy affected by entering and exiting transitions.
-     *
-     * @param transitioningViews This View will be added to transitioningViews if it has a
-     * transition name, is VISIBLE and a normal View, or a ViewGroup with
-     * [android.view.ViewGroup.isTransitionGroup] true.
-     * @param view The base of the view hierarchy to look in.
-     */
-    private fun captureTransitioningViews(transitioningViews: ArrayList<View>, view: View) {
-        if (view is ViewGroup) {
-            if (ViewGroupCompat.isTransitionGroup(view)) {
-                if (!transitioningViews.contains(view)) {
-                    transitioningViews.add(view)
-                }
-            } else {
-                val count = view.childCount
-                for (i in 0 until count) {
-                    val child = view.getChildAt(i)
-                    if (child.visibility == View.VISIBLE) {
-                        captureTransitioningViews(transitioningViews, child)
-                    }
-                }
-            }
-        } else {
-            if (!transitioningViews.contains(view)) {
-                transitioningViews.add(view)
-            }
-        }
-    }
-
-    /**
      * Finds all views that have transition names in the hierarchy under the given view and
      * stores them in [namedViews] map with the name as the key.
      */
@@ -643,12 +408,7 @@
         }
     }
 
-    private fun applyContainerChanges(operation: Operation) {
-        val view = operation.fragment.mView
-        operation.finalState.applyState(view)
-    }
-
-    private open class SpecialEffectsInfo(
+    internal open class SpecialEffectsInfo(
         val operation: Operation,
         val signal: CancellationSignal
     ) {
@@ -768,6 +528,11 @@
 
     private class AnimationEffect(val animationInfo: AnimationInfo) : Effect() {
         override fun onCommit(container: ViewGroup) {
+            if (animationInfo.isVisibilityUnchanged) {
+                // No change in visibility, so we can immediately complete the animation
+                animationInfo.completeSpecialEffect()
+                return
+            }
             val context = container.context
             val operation: Operation = animationInfo.operation
             val fragment = operation.fragment
@@ -834,9 +599,16 @@
     }
 
     private class AnimatorEffect(val animatorInfo: AnimationInfo) : Effect() {
+        override val isSeekingSupported: Boolean
+            get() = true
+        var animator: AnimatorSet? = null
         override fun onStart(container: ViewGroup) {
+            if (animatorInfo.isVisibilityUnchanged) {
+                // No change in visibility, so we can avoid starting the animator
+                return
+            }
             val context = container.context
-            val animator = animatorInfo.getAnimation(context)?.animator
+            animator = animatorInfo.getAnimation(context)?.animator
             val operation: Operation = animatorInfo.operation
             val fragment = operation.fragment
 
@@ -866,49 +638,52 @@
             }
         }
 
-        override fun onCommit(container: ViewGroup) {
+        override fun onProgress(backEvent: BackEventCompat, container: ViewGroup) {
             val operation = animatorInfo.operation
-            val animatorSet = animatorInfo.getAnimation(container.context)?.animator
-            if (animatorSet != null &&
-                Build.VERSION.SDK_INT >= 34 && operation.fragment.mTransitioning
-                ) {
+            val animatorSet = animator
+            if (animatorSet == null) {
+                // No change in visibility, so we can go ahead and complete the effect
+                animatorInfo.completeSpecialEffect()
+                return
+            }
+
+            if (Build.VERSION.SDK_INT >= 34 && operation.fragment.mTransitioning) {
                 if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
                     Log.v(
                         FragmentManager.TAG,
                         "Adding BackProgressCallbacks for Animators to operation $operation"
                     )
                 }
-                operation.addBackProgressCallbacks({ backEvent ->
-                    val totalDuration = Api24Impl.totalDuration(animatorSet)
-                    var time = (backEvent.progress * totalDuration).toLong()
-                    // We cannot let the time get to 0 or the totalDuration to avoid
-                    // completing the operation accidentally.
-                    if (time == 0L) {
-                        time = 1L
-                    }
-                    if (time == totalDuration) {
-                        time = totalDuration - 1
-                    }
-                    if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
-                        Log.v(
-                            FragmentManager.TAG,
-                            "Setting currentPlayTime to $time for Animator $animatorSet on " +
-                                "operation $operation"
-                        )
-                    }
-                    Api26Impl.setCurrentPlayTime(animatorSet, time)
-                }) {
-                    if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
-                        Log.v(
-                            FragmentManager.TAG,
-                            "Back Progress Callback Animator has been started."
-                        )
-                    }
-                    animatorSet.start()
+                val totalDuration = Api24Impl.totalDuration(animatorSet)
+                var time = (backEvent.progress * totalDuration).toLong()
+                // We cannot let the time get to 0 or the totalDuration to avoid
+                // completing the operation accidentally.
+                if (time == 0L) {
+                    time = 1L
                 }
-            } else {
-                animatorSet?.start()
+                if (time == totalDuration) {
+                    time = totalDuration - 1
+                }
+                if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                    Log.v(
+                        FragmentManager.TAG,
+                        "Setting currentPlayTime to $time for Animator $animatorSet on " +
+                            "operation $operation"
+                    )
+                }
+                Api26Impl.setCurrentPlayTime(animatorSet, time)
             }
+        }
+
+        override fun onCommit(container: ViewGroup) {
+            val operation = animatorInfo.operation
+            val animatorSet = animator
+            if (animatorSet == null) {
+                // No change in visibility, so we can go ahead and complete the effect
+                animatorInfo.completeSpecialEffect()
+                return
+            }
+            animatorSet.start()
             if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
                 Log.v(FragmentManager.TAG,
                     "Animator from operation $operation has started.")
@@ -916,8 +691,11 @@
         }
 
         override fun onCancel(container: ViewGroup) {
-            val animator = animatorInfo.getAnimation(container.context)?.animator
-            if (animator != null) {
+            val animator = animator
+            if (animator == null) {
+                // No change in visibility, so we can go ahead and complete the effect
+                animatorInfo.completeSpecialEffect()
+            } else {
                 val operation = animatorInfo.operation
                 if (operation.isSeeking) {
                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -942,14 +720,184 @@
         val firstOut: Operation?,
         val lastIn: Operation?,
         val transitionImpl: FragmentTransitionImpl,
-        val mergedTransition: Any,
-        val enteringViews: List<View>,
         val sharedElementTransition: Any?,
         val sharedElementFirstOutViews: ArrayList<View>,
         val sharedElementLastInViews: ArrayList<View>,
-        val sharedElementNameMapping: Map<String, String>
+        val sharedElementNameMapping: ArrayMap<String, String>,
+        val enteringNames: ArrayList<String>,
+        val exitingNames: ArrayList<String>,
+        val firstOutViews: ArrayMap<String, View>,
+        val lastInViews: ArrayMap<String, View>,
+        val isPop: Boolean,
+        val startedTransitions: MutableMap<Operation, Boolean>
     ) : Effect() {
         override fun onCommit(container: ViewGroup) {
+            // Every transition needs to target at least one View so that they
+            // don't interfere with one another. This is the view we use
+            // in cases where there are no real views to target
+            val nonExistentView = View(container.context)
+            var firstOutEpicenterView: View? = null
+            var hasLastInEpicenter = false
+            val lastInEpicenterRect = Rect()
+            for (transitionInfo: TransitionInfo in transitionInfos) {
+                val hasSharedElementTransition = transitionInfo.hasSharedElementTransition()
+                // Compute the shared element transition between the firstOut and lastIn Fragments
+                if (hasSharedElementTransition && (firstOut != null) && (lastIn != null)) {
+                    if (sharedElementNameMapping.isNotEmpty() && sharedElementTransition != null) {
+                        // Call through to onSharedElementStart() before capturing the
+                        // starting values for the shared element transition
+                        callSharedElementStartEnd(lastIn.fragment, firstOut.fragment, isPop,
+                            firstOutViews, true)
+                        // Trigger the onSharedElementEnd callback in the next frame after
+                        // the starting values are captured and before capturing the end states
+                        OneShotPreDrawListener.add(container) {
+                            callSharedElementStartEnd(lastIn.fragment, firstOut.fragment, isPop,
+                                lastInViews, false)
+                        }
+                        sharedElementFirstOutViews.addAll(firstOutViews.values)
+
+                        // Compute the epicenter of the firstOut transition
+                        if (exitingNames.isNotEmpty()) {
+                            val epicenterViewName = exitingNames[0]
+                            firstOutEpicenterView = firstOutViews[epicenterViewName]
+                            transitionImpl.setEpicenter(
+                                sharedElementTransition, firstOutEpicenterView
+                            )
+                        }
+                        sharedElementLastInViews.addAll(lastInViews.values)
+
+                        // Compute the epicenter of the lastIn transition
+                        if (enteringNames.isNotEmpty()) {
+                            val epicenterViewName = enteringNames[0]
+                            val lastInEpicenterView = lastInViews[epicenterViewName]
+                            if (lastInEpicenterView != null) {
+                                hasLastInEpicenter = true
+                                // We can't set the epicenter here directly since the View might
+                                // not have been laid out as of yet, so instead we set a Rect as
+                                // the epicenter and compute the bounds one frame later
+                                val impl: FragmentTransitionImpl = transitionImpl
+                                OneShotPreDrawListener.add(container) {
+                                    impl.getBoundsOnScreen(lastInEpicenterView, lastInEpicenterRect)
+                                }
+                            }
+                        }
+
+                        // Now set the transition's targets to only the firstOut Fragment's views
+                        // It'll be swapped to the lastIn Fragment's views after the
+                        // transition is started
+                        transitionImpl.setSharedElementTargets(sharedElementTransition,
+                            nonExistentView, sharedElementFirstOutViews)
+                        // After the swap to the lastIn Fragment's view (done below), we
+                        // need to clean up those targets. We schedule this here so that it
+                        // runs directly after the swap
+                        transitionImpl.scheduleRemoveTargets(sharedElementTransition, null, null,
+                            null, null, sharedElementTransition, sharedElementLastInViews)
+                        // Both the firstOut and lastIn Operations are now associated
+                        // with a Transition
+                        startedTransitions[firstOut] = true
+                        startedTransitions[lastIn] = true
+                    }
+                }
+            }
+            val enteringViews = ArrayList<View>()
+            // These transitions run together, overlapping one another
+            var mergedTransition: Any? = null
+            // These transitions run only after all of the other transitions complete
+            var mergedNonOverlappingTransition: Any? = null
+            // Now iterate through the set of transitions and merge them together
+            for (transitionInfo: TransitionInfo in transitionInfos) {
+                val operation: Operation = transitionInfo.operation
+                if (transitionInfo.isVisibilityUnchanged) {
+                    // No change in visibility, so we can immediately complete the transition
+                    startedTransitions[transitionInfo.operation] = false
+                    transitionInfo.completeSpecialEffect()
+                    continue
+                }
+                val transition = transitionImpl.cloneTransition(transitionInfo.transition)
+                val involvedInSharedElementTransition = (sharedElementTransition != null &&
+                    (operation === firstOut || operation === lastIn))
+                if (transition == null) {
+                    // Nothing more to do if the transition is null
+                    if (!involvedInSharedElementTransition) {
+                        // Only complete the transition if this fragment isn't involved
+                        // in the shared element transition (as otherwise we need to wait
+                        // for that to finish)
+                        startedTransitions[operation] = false
+                        transitionInfo.completeSpecialEffect()
+                    }
+                } else {
+                    // Target the Transition to *only* the set of transitioning views
+                    val transitioningViews = ArrayList<View>()
+                    captureTransitioningViews(transitioningViews, operation.fragment.mView)
+                    if (involvedInSharedElementTransition) {
+                        // Remove all of the shared element views from the transition
+                        if (operation === firstOut) {
+                            transitioningViews.removeAll(sharedElementFirstOutViews.toSet())
+                        } else {
+                            transitioningViews.removeAll(sharedElementLastInViews.toSet())
+                        }
+                    }
+                    if (transitioningViews.isEmpty()) {
+                        transitionImpl.addTarget(transition, nonExistentView)
+                    } else {
+                        transitionImpl.addTargets(transition, transitioningViews)
+                        transitionImpl.scheduleRemoveTargets(transition, transition,
+                            transitioningViews, null, null, null, null)
+                        if (operation.finalState === Operation.State.GONE) {
+                            // We're hiding the Fragment. This requires a bit of extra work
+                            // First, we need to avoid immediately applying the container change as
+                            // that will stop the Transition from occurring.
+                            operation.isAwaitingContainerChanges = false
+                            // Then schedule the actual hide of the fragment's view,
+                            // essentially doing what applyState() would do for us
+                            val transitioningViewsToHide = ArrayList(transitioningViews)
+                            transitioningViewsToHide.remove(operation.fragment.mView)
+                            transitionImpl.scheduleHideFragmentView(transition,
+                                operation.fragment.mView, transitioningViewsToHide)
+                            // This OneShotPreDrawListener gets fired before the delayed start of
+                            // the Transition and changes the visibility of any exiting child views
+                            // that *ARE NOT* shared element transitions. The TransitionManager then
+                            // properly considers exiting views and marks them as disappearing,
+                            // applying a transition and a listener to take proper actions once the
+                            // transition is complete.
+                            OneShotPreDrawListener.add(container) {
+                                setViewVisibility(transitioningViews, View.INVISIBLE)
+                            }
+                        }
+                    }
+                    if (operation.finalState === Operation.State.VISIBLE) {
+                        enteringViews.addAll(transitioningViews)
+                        if (hasLastInEpicenter) {
+                            transitionImpl.setEpicenter(transition, lastInEpicenterRect)
+                        }
+                    } else {
+                        transitionImpl.setEpicenter(transition, firstOutEpicenterView)
+                    }
+                    startedTransitions[operation] = true
+                    // Now determine how this transition should be merged together
+                    if (transitionInfo.isOverlapAllowed) {
+                        // Overlap is allowed, so add them to the mergeTransition set
+                        mergedTransition = transitionImpl.mergeTransitionsTogether(
+                            mergedTransition, transition, null)
+                    } else {
+                        // Overlap is not allowed, add them to the mergedNonOverlappingTransition
+                        mergedNonOverlappingTransition = transitionImpl.mergeTransitionsTogether(
+                            mergedNonOverlappingTransition, transition, null)
+                    }
+                }
+            }
+
+            // Make sure that the mergedNonOverlappingTransition set
+            // runs after the mergedTransition set is complete
+            mergedTransition = transitionImpl.mergeTransitionsInSequence(mergedTransition,
+                mergedNonOverlappingTransition, sharedElementTransition)
+
+            // If there's no transitions playing together, no non-overlapping transitions,
+            // and no shared element transitions, mergedTransition will be null and
+            // there's nothing else we need to do
+            if (mergedTransition == null) {
+                return
+            }
             // Now set up our completion signal on the completely merged transition set
             transitionInfos.filterNot { transitionInfo ->
                 // If there's change in visibility, we've already completed the transition
@@ -1015,10 +963,45 @@
             setViewVisibility(enteringViews, View.VISIBLE)
             transitionImpl.swapSharedElementTargets(sharedElementTransition,
                 sharedElementFirstOutViews, sharedElementLastInViews)
+
+            if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                Log.v(FragmentManager.TAG,
+                    "Completed executing operations from $firstOut to $lastIn")
+            }
+        }
+
+        /**
+         * Gets the Views in the hierarchy affected by entering and exiting transitions.
+         *
+         * @param transitioningViews This View will be added to transitioningViews if it has a
+         * transition name, is VISIBLE and a normal View, or a ViewGroup with
+         * [android.view.ViewGroup.isTransitionGroup] true.
+         * @param view The base of the view hierarchy to look in.
+         */
+        private fun captureTransitioningViews(transitioningViews: ArrayList<View>, view: View) {
+            if (view is ViewGroup) {
+                if (ViewGroupCompat.isTransitionGroup(view)) {
+                    if (!transitioningViews.contains(view)) {
+                        transitioningViews.add(view)
+                    }
+                } else {
+                    val count = view.childCount
+                    for (i in 0 until count) {
+                        val child = view.getChildAt(i)
+                        if (child.visibility == View.VISIBLE) {
+                            captureTransitioningViews(transitioningViews, child)
+                        }
+                    }
+                }
+            } else {
+                if (!transitioningViews.contains(view)) {
+                    transitioningViews.add(view)
+                }
+            }
         }
     }
 
-    private class NoOpEffect(val info: SpecialEffectsInfo) : Effect() {
+    internal class NoOpEffect(val info: SpecialEffectsInfo) : Effect() {
         override fun onCommit(container: ViewGroup) {
             info.completeSpecialEffect()
         }
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/FragmentManager.java b/fragment/fragment/src/main/java/androidx/fragment/app/FragmentManager.java
index 37ed965..4b36bc9 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/FragmentManager.java
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/FragmentManager.java
@@ -116,7 +116,7 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY)
     public static final String TAG = "FragmentManager";
 
-    static boolean USE_PREDICTIVE_BACK = false;
+    static boolean USE_PREDICTIVE_BACK = true;
 
     /**
      * Control whether FragmentManager uses the new state predictive back feature that allows
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt b/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
index f707918..a556ef8 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
@@ -240,11 +240,38 @@
                         "SpecialEffectsController: Executing pending operations"
                     )
                 }
-                for (operation in newPendingOperations) {
-                    operation.onStart()
-                }
                 collectEffects(newPendingOperations, operationDirectionIsPop)
-                commitEffects(newPendingOperations)
+                var seekable = true
+                var transitioning = true
+                newPendingOperations.forEach { operation ->
+                    seekable = operation.effects.filter { effect ->
+                        // We don't want noOpEffects changing our seeking
+                        effect !is DefaultSpecialEffectsController.NoOpEffect
+                    }.all { effect ->
+                        effect.isSeekingSupported
+                    }
+                    if (operation.effects.all {
+                            it is DefaultSpecialEffectsController.NoOpEffect
+                    }) {
+                        seekable = false
+                    }
+                    if (!operation.fragment.mTransitioning) {
+                        transitioning = false
+                    }
+                }
+
+                if (!transitioning) {
+                    processStart(newPendingOperations)
+                    commitEffects(newPendingOperations)
+                } else {
+                    if (seekable) {
+                        processStart(newPendingOperations)
+                        for (i in newPendingOperations.indices) {
+                            val operation = newPendingOperations[i]
+                            applyContainerChangesToOperation(operation)
+                        }
+                    }
+                }
                 operationDirectionIsPop = false
                 if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
                     Log.v(
@@ -256,6 +283,13 @@
         }
     }
 
+    internal fun applyContainerChangesToOperation(operation: Operation) {
+        if (operation.isAwaitingContainerChanges) {
+            operation.finalState.applyState(operation.fragment.requireView())
+            operation.isAwaitingContainerChanges = false
+        }
+    }
+
     fun forceCompleteAllOperations() {
         if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
             Log.v(
@@ -266,9 +300,7 @@
         val attachedToWindow = ViewCompat.isAttachedToWindow(container)
         synchronized(pendingOperations) {
             updateFinalState()
-            for (operation in pendingOperations) {
-                operation.onStart()
-            }
+            processStart(pendingOperations)
 
             // First cancel running operations
             val runningOperations = runningOperations.toMutableList()
@@ -321,16 +353,14 @@
     }
 
     /**
-     * Execute all of the given operations.
+     * Collect all of the given operations.
      *
      * If there are no special effects for a given operation, the SpecialEffectsController
      * should call [Operation.complete]. Otherwise, a
      * [CancellationSignal] representing each special effect should be added via
      * [Operation.markStartedSpecialEffect], calling
      * [Operation.completeSpecialEffect] when that specific
-     * special effect finishes. When the last started special effect is completed,
-     * [Operation.completeSpecialEffect] will call
-     * [Operation.complete] automatically.
+     * special effect finishes.
      *
      * It is **strongly recommended** that each [CancellationSignal] added with
      * [Operation.markStartedSpecialEffect] listen for cancellation,
@@ -343,7 +373,41 @@
      */
     abstract fun collectEffects(operations: List<@JvmSuppressWildcards Operation>, isPop: Boolean)
 
-    open fun commitEffects(operations: List<@JvmSuppressWildcards Operation>) { }
+    /**
+     * Commit all of the given operations.
+     *
+     * This commits all of the effects of the operations. When the last started special effect is
+     * completed, [Operation.completeSpecialEffect] will call [Operation.complete] automatically.
+     *
+     * @param operations the list of operations to execute in order.
+     */
+    internal open fun commitEffects(operations: List<@JvmSuppressWildcards Operation>) {
+        val set = operations.flatMap { it.effects }.toSet().toList()
+
+        // Commit all of the Animation, Animator, Transition and NoOp Effects we have collected
+        for (i in set.indices) {
+            val effect = set[i]
+            effect.onCommit(container)
+        }
+
+        for (i in operations.indices) {
+            val operation = operations[i]
+            applyContainerChangesToOperation(operation)
+        }
+    }
+
+    private fun processStart(operations: List<@JvmSuppressWildcards Operation>) {
+        for (i in operations.indices) {
+            val operation = operations[i]
+            operation.onStart()
+        }
+        val set = operations.flatMap { it.effects }.toSet().toList()
+        // Start all of the Animation, Animator, Transition and NoOp Effects we have collected
+        for (j in set.indices) {
+            val effect = set[j]
+            effect.performStart(container)
+        }
+    }
 
     fun processProgress(backEvent: BackEventCompat) {
         if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
@@ -352,8 +416,11 @@
                 "SpecialEffectsController: Processing Progress ${backEvent.progress}"
             )
         }
-        runningOperations.forEach { operation ->
-            operation.backInProgressListener?.invoke(backEvent)
+
+        val set = runningOperations.flatMap { it.effects }.toSet().toList()
+        for (j in set.indices) {
+            val effect = set[j]
+            effect.onProgress(backEvent, container)
         }
     }
 
@@ -364,9 +431,8 @@
                 "SpecialEffectsController: Completing Back "
             )
         }
-        runningOperations.forEach { operation ->
-            operation.backOnCompleteListener?.invoke()
-        }
+        processStart(runningOperations)
+        commitEffects(runningOperations)
     }
 
     /**
@@ -516,10 +582,6 @@
 
         private val completionListeners = mutableListOf<Runnable>()
         private val specialEffectsSignals = mutableSetOf<CancellationSignal>()
-        var backInProgressListener: ((BackEventCompat) -> Unit)? = null
-            private set
-        var backOnCompleteListener: (() -> Unit)? = null
-            private set
         var isCanceled = false
             private set
         var isComplete = false
@@ -534,8 +596,6 @@
 
         val effects = mutableListOf<Effect>()
 
-        var transitionEffect: Effect? = null
-
         init {
             // Connect the CancellationSignal to our own
             cancellationSignal.setOnCancelListener { cancel() }
@@ -622,20 +682,6 @@
             completionListeners.add(listener)
         }
 
-        fun addBackProgressCallbacks(
-            onProgress: (BackEventCompat) -> Unit,
-            onComplete: () -> Unit
-        ) {
-            if (FragmentManager.isLoggingEnabled(Log.DEBUG)) {
-                Log.d(
-                    FragmentManager.TAG,
-                    "SpecialEffectsController: Adding back progress callbacks for operation $this"
-                )
-            }
-            backInProgressListener = onProgress
-            backOnCompleteListener = onComplete
-        }
-
         /**
          * Callback for when the operation is about to start.
          */
@@ -650,7 +696,6 @@
          * @param signal A CancellationSignal that can be used to cancel this special effect.
          */
         fun markStartedSpecialEffect(signal: CancellationSignal) {
-            onStart()
             specialEffectsSignals.add(signal)
         }
 
@@ -684,8 +729,6 @@
                 )
             }
             isComplete = true
-            backInProgressListener = null
-            backOnCompleteListener = null
             completionListeners.forEach { listener ->
                 listener.run()
             }
@@ -755,8 +798,21 @@
     }
 
     internal open class Effect {
+        open val isSeekingSupported = false
+
+        private var isStarted = false
+
+        fun performStart(container: ViewGroup) {
+            if (!isStarted) {
+                onStart(container)
+            }
+            isStarted = true
+        }
+
         open fun onStart(container: ViewGroup) { }
 
+        open fun onProgress(backEvent: BackEventCompat, container: ViewGroup) { }
+
         open fun onCommit(container: ViewGroup) { }
 
         open fun onCancel(container: ViewGroup) { }
diff --git a/glance/glance-testing/api/current.txt b/glance/glance-testing/api/current.txt
new file mode 100644
index 0000000..51dd245
--- /dev/null
+++ b/glance/glance-testing/api/current.txt
@@ -0,0 +1,36 @@
+// Signature format: 4.0
+package androidx.glance.testing {
+
+  public abstract class GlanceNode<T> {
+    method public abstract java.util.List<androidx.glance.testing.GlanceNode<T>> children();
+    method public final T getValue();
+    method public abstract String toDebugString();
+    property public final T value;
+  }
+
+  public final class GlanceNodeAssertion<R, T extends androidx.glance.testing.GlanceNode<R>> {
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assert(androidx.glance.testing.GlanceNodeMatcher<R> matcher, optional kotlin.jvm.functions.Function0<java.lang.String>? messagePrefixOnError);
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertDoesNotExist();
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertExists();
+  }
+
+  public final class GlanceNodeMatcher<R> {
+    ctor public GlanceNodeMatcher(String description, kotlin.jvm.functions.Function1<? super androidx.glance.testing.GlanceNode<R>,java.lang.Boolean> matcher);
+    method public boolean matches(androidx.glance.testing.GlanceNode<R> node);
+    method public boolean matchesAny(Iterable<? extends androidx.glance.testing.GlanceNode<R>> nodes);
+  }
+
+}
+
+package androidx.glance.testing.unit {
+
+  public final class FiltersKt {
+    method public static androidx.glance.testing.GlanceNodeMatcher<androidx.glance.testing.unit.MappedNode> hasTestTag(String testTag);
+    method public static androidx.glance.testing.GlanceNodeMatcher<androidx.glance.testing.unit.MappedNode> hasText(String text, optional boolean substring, optional boolean ignoreCase);
+  }
+
+  public final class MappedNode {
+  }
+
+}
+
diff --git a/glance/glance-testing/api/res-current.txt b/glance/glance-testing/api/res-current.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/glance/glance-testing/api/res-current.txt
diff --git a/glance/glance-testing/api/restricted_current.txt b/glance/glance-testing/api/restricted_current.txt
new file mode 100644
index 0000000..51dd245
--- /dev/null
+++ b/glance/glance-testing/api/restricted_current.txt
@@ -0,0 +1,36 @@
+// Signature format: 4.0
+package androidx.glance.testing {
+
+  public abstract class GlanceNode<T> {
+    method public abstract java.util.List<androidx.glance.testing.GlanceNode<T>> children();
+    method public final T getValue();
+    method public abstract String toDebugString();
+    property public final T value;
+  }
+
+  public final class GlanceNodeAssertion<R, T extends androidx.glance.testing.GlanceNode<R>> {
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assert(androidx.glance.testing.GlanceNodeMatcher<R> matcher, optional kotlin.jvm.functions.Function0<java.lang.String>? messagePrefixOnError);
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertDoesNotExist();
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertExists();
+  }
+
+  public final class GlanceNodeMatcher<R> {
+    ctor public GlanceNodeMatcher(String description, kotlin.jvm.functions.Function1<? super androidx.glance.testing.GlanceNode<R>,java.lang.Boolean> matcher);
+    method public boolean matches(androidx.glance.testing.GlanceNode<R> node);
+    method public boolean matchesAny(Iterable<? extends androidx.glance.testing.GlanceNode<R>> nodes);
+  }
+
+}
+
+package androidx.glance.testing.unit {
+
+  public final class FiltersKt {
+    method public static androidx.glance.testing.GlanceNodeMatcher<androidx.glance.testing.unit.MappedNode> hasTestTag(String testTag);
+    method public static androidx.glance.testing.GlanceNodeMatcher<androidx.glance.testing.unit.MappedNode> hasText(String text, optional boolean substring, optional boolean ignoreCase);
+  }
+
+  public final class MappedNode {
+  }
+
+}
+
diff --git a/glance/glance-testing/build.gradle b/glance/glance-testing/build.gradle
new file mode 100644
index 0000000..455baee
--- /dev/null
+++ b/glance/glance-testing/build.gradle
@@ -0,0 +1,48 @@
+/*
+ * 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.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.library")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(project(":glance:glance"))
+
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.testCore)
+    testImplementation(libs.testRules)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+}
+
+android {
+    namespace "androidx.glance.testing"
+}
+
+androidx {
+    name = "Glance Testing"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2023"
+    description = "This library provides base APIs to enable testing Glance"
+    targetsJavaConsumers = false
+}
\ No newline at end of file
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/AssertionErrorMessages.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/AssertionErrorMessages.kt
new file mode 100644
index 0000000..671a692
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/AssertionErrorMessages.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.glance.testing
+
+import java.lang.StringBuilder
+
+/**
+ * Builds error message for case where expected amount of matching nodes does not match reality.
+ *
+ * Provide [errorMessage] to explain which operation you were about to perform. This makes it
+ * easier for developer to find where the failure happened.
+ */
+internal fun buildErrorMessageForCountMismatch(
+    errorMessage: String,
+    matcherDescription: String,
+    expectedCount: Int,
+    actualCount: Int
+): String {
+    val sb = StringBuilder()
+
+    sb.append(errorMessage)
+    sb.append("\n")
+
+    sb.append("Reason: ")
+    when (expectedCount) {
+        0 -> {
+            sb.append("Did not expect any node matching condition: $matcherDescription")
+        }
+
+        else -> {
+            sb.append("Expected '$expectedCount' node(s) matching condition: $matcherDescription")
+        }
+    }
+
+    sb.append(", but found '$actualCount'")
+
+    return sb.toString()
+}
+
+/**
+ * Builds error message for general assertion errors.
+ *
+ * <p>Provide [errorMessage] to explain which operation you were about to perform. This makes it
+ * easier for developer to find where the failure happened.
+ */
+internal fun <R> buildGeneralErrorMessage(
+    errorMessage: String,
+    glanceNode: GlanceNode<R>
+): String {
+    val sb = StringBuilder()
+    sb.append(errorMessage)
+
+    sb.append("\n")
+    sb.append("Glance Node: ${glanceNode.toDebugString()}")
+
+    return sb.toString()
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNode.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNode.kt
new file mode 100644
index 0000000..8f4c251
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNode.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.glance.testing
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
+
+/**
+ * A wrapper for Glance composable node under test.
+ *
+ * @param T A representation of Glance composable node (e.g. MappedNode) on which assertions can be
+ *          performed
+ * @param value an object of the representation of the Glance composable node
+ */
+abstract class GlanceNode<T> @RestrictTo(Scope.LIBRARY_GROUP) constructor(val value: T) {
+    /**
+     * Returns children of current glance node.
+     */
+    abstract fun children(): List<GlanceNode<T>>
+
+    /**
+     * Returns the Glance node as string that can be presented in error messages helping developer
+     * debug the assertion error.
+     */
+    abstract fun toDebugString(): String
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is GlanceNode<*>) return false
+        return value == other.value
+    }
+
+    override fun hashCode(): Int {
+        val result = value.hashCode()
+        return 31 * result
+    }
+
+    override fun toString(): String {
+        return ("GlanceNode{value='$value}'")
+    }
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertion.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertion.kt
new file mode 100644
index 0000000..31a1c99
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertion.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.glance.testing
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
+
+/**
+ * Represents a Glance node from the tree that can be asserted on.
+ *
+ * An instance of [GlanceNodeAssertion] can be obtained from {@code onNode} and equivalent methods
+ * on a GlanceNodeAssertionProvider
+ */
+class GlanceNodeAssertion<R, T : GlanceNode<R>> @RestrictTo(Scope.LIBRARY_GROUP) constructor(
+    private val matcher: GlanceNodeMatcher<R>,
+    private val testContext: TestContext<R, T>,
+) {
+    /**
+     * Asserts that the node was found.
+
+     * @throws [AssertionError] if the assert fails.
+     */
+    fun assertExists(): GlanceNodeAssertion<R, T> {
+        findSingleMatchingNode(finalErrorMessageOnFail = "Failed assertExists")
+        return this
+    }
+
+    /**
+     * Asserts that no matching node was found.
+
+     * @throws [AssertionError] if the assert fails.
+     */
+    fun assertDoesNotExist(): GlanceNodeAssertion<R, T> {
+        val matchedNodesCount = findMatchingNodes().size
+        if (matchedNodesCount != 0) {
+            throw AssertionError(
+                buildErrorMessageForCountMismatch(
+                    errorMessage = "Failed assertDoesNotExist",
+                    matcherDescription = matcher.description,
+                    expectedCount = 0,
+                    actualCount = matchedNodesCount
+                )
+            )
+        }
+        return this
+    }
+
+    /**
+     * Asserts that the provided [matcher] is satisfied for this node.
+     *
+     * <p> This function also can be used to create convenience "assert{somethingConcrete}"
+     * methods as extension functions on the GlanceNodeAssertion.
+     *
+     * @param matcher Matcher to verify.
+     * @param messagePrefixOnError Prefix to be put in front of an error that gets thrown in case
+     * this assert fails. This can be helpful in situations where this assert fails as part of a
+     * bigger operation that used this assert as a precondition check.
+     *
+     * @throws AssertionError if the matcher does not match or the node can no longer be found.
+     */
+    fun assert(
+        matcher: GlanceNodeMatcher<R>,
+        messagePrefixOnError: (() -> String)? = null
+    ): GlanceNodeAssertion<R, T> {
+        var errorMessageOnFail = "Failed to assert condition: (${matcher.description})"
+        if (messagePrefixOnError != null) {
+            errorMessageOnFail = messagePrefixOnError() + "\n" + errorMessageOnFail
+        }
+        val glanceNode = findSingleMatchingNode(errorMessageOnFail)
+
+        if (!matcher.matches(glanceNode)) {
+            throw AssertionError(
+                buildGeneralErrorMessage(
+                    errorMessageOnFail,
+                    glanceNode
+                )
+            )
+        }
+        return this
+    }
+
+    private fun findSingleMatchingNode(finalErrorMessageOnFail: String): GlanceNode<R> {
+        val matchingNodes = findMatchingNodes()
+        val matchedNodesCount = matchingNodes.size
+        if (matchedNodesCount != 1) {
+            throw AssertionError(
+                buildErrorMessageForCountMismatch(
+                    finalErrorMessageOnFail,
+                    matcher.description,
+                    expectedCount = 1,
+                    actualCount = matchedNodesCount
+                )
+            )
+        }
+        return matchingNodes.single()
+    }
+
+    private fun findMatchingNodes(): List<GlanceNode<R>> {
+        if (testContext.cachedMatchedNodes.isEmpty()) {
+            val rootGlanceNode =
+                checkNotNull(testContext.rootGlanceNode) { "No root GlanceNode found." }
+            testContext.cachedMatchedNodes = findMatchingNodes(rootGlanceNode)
+        }
+        return testContext.cachedMatchedNodes
+    }
+
+    private fun findMatchingNodes(node: GlanceNode<R>): List<GlanceNode<R>> {
+        val matching = mutableListOf<GlanceNode<R>>()
+        if (matcher.matches(node)) {
+            matching.add(node)
+        }
+        for (child in node.children()) {
+            matching.addAll(findMatchingNodes(child))
+        }
+        return matching.toList()
+    }
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeMatcher.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeMatcher.kt
new file mode 100644
index 0000000..0e9fe2a
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeMatcher.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.glance.testing
+
+/**
+ * Wrapper for matcher lambdas and related details
+ *
+ * @param description a string explaining to the developer what conditions were being tested.
+ * @param matcher a lambda performing the actual logic of matching on the GlanceNode
+ */
+class GlanceNodeMatcher<R>(
+    internal val description: String,
+    private val matcher: (GlanceNode<R>) -> Boolean
+) {
+    /**
+     * Returns whether the given node is matched by this matcher.
+     */
+    fun matches(node: GlanceNode<R>): Boolean {
+        return matcher(node)
+    }
+
+    /**
+     * Returns whether at least one of the given nodes is matched by this matcher.
+     */
+    fun matchesAny(nodes: Iterable<GlanceNode<R>>): Boolean {
+        return nodes.any(matcher)
+    }
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt
new file mode 100644
index 0000000..1399349
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.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.glance.testing
+
+import androidx.annotation.RestrictTo
+
+/**
+ * A context object that holds glance node tree being inspected as well as any state cached
+ * across the chain of assertions.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class TestContext<R, T : GlanceNode<R>> {
+    var rootGlanceNode: T? = null
+    var cachedMatchedNodes: List<GlanceNode<R>> = emptyList()
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/Filters.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/Filters.kt
new file mode 100644
index 0000000..877cca9
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/Filters.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.glance.testing.unit
+
+import androidx.glance.EmittableWithText
+import androidx.glance.semantics.SemanticsModifier
+import androidx.glance.semantics.SemanticsProperties
+import androidx.glance.semantics.SemanticsPropertyKey
+import androidx.glance.testing.GlanceNodeMatcher
+
+/**
+ * Returns a matcher that matches if a node is annotated by the given test tag.
+ *
+ * <p>This can be passed in "onNode" and "onNodeAll" functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
+ * @param testTag value to match against the free form string specified in {@code testTag} semantics
+ *                modifier on Glance composable nodes.
+ */
+fun hasTestTag(testTag: String): GlanceNodeMatcher<MappedNode> =
+    hasSemanticsPropertyValue(SemanticsProperties.TestTag, testTag)
+
+private fun <T> hasSemanticsPropertyValue(
+    key: SemanticsPropertyKey<T>,
+    expectedValue: T
+): GlanceNodeMatcher<MappedNode> {
+    return GlanceNodeMatcher("${key.name} = '$expectedValue'") { node ->
+        node.value.emittable.modifier.any {
+            it is SemanticsModifier &&
+                it.configuration.getOrElseNullable(key) { null } == expectedValue
+        }
+    }
+}
+
+/**
+ * Returns a matcher that matches if text on node matches the provided text.
+ *
+ * <p>This can be passed in "onNode" and "onNodeAll" functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
+ * @param text value to match.
+ * @param substring whether to perform substring matching
+ * @param ignoreCase whether to perform case insensitive matching
+ */
+fun hasText(
+    text: String,
+    substring: Boolean = false,
+    ignoreCase: Boolean = false
+): GlanceNodeMatcher<MappedNode> = GlanceNodeMatcher(
+    if (substring) {
+        "contains '$text' (ignoreCase: $ignoreCase) as substring"
+    } else {
+        "has text = '$text' (ignoreCase: '$ignoreCase')"
+    }
+) { node ->
+    val emittable = node.value.emittable
+    if (emittable is EmittableWithText) {
+        if (substring) {
+            emittable.text.contains(text, ignoreCase)
+        } else {
+            emittable.text.equals(text, ignoreCase)
+        }
+    } else {
+        false
+    }
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/GlanceMappedNode.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/GlanceMappedNode.kt
new file mode 100644
index 0000000..3cd1bd9
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/GlanceMappedNode.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.glance.testing.unit
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
+import androidx.glance.Emittable
+import androidx.glance.EmittableWithChildren
+import androidx.glance.testing.GlanceNode
+
+/**
+ * Hold a Glance composable node in a form that enables performing assertion on it.
+ *
+ * <p>[MappedNode]s are not rendered representations, but they map 1:1 to the composable nodes. They
+ * enable faster testing of the logic of composing Glance composable tree as part of unit tests.
+ */
+class MappedNode internal constructor(internal val emittable: Emittable) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is MappedNode) return false
+        return emittable == other.emittable
+    }
+
+    override fun hashCode(): Int {
+        val result = emittable.hashCode()
+        return 31 * result
+    }
+
+    override fun toString(): String {
+        return ("MappedNode{emittable='$emittable}'")
+    }
+}
+
+/**
+ * An implementation of [GlanceNode] node that uses [MappedNode] to perform assertions during
+ * testing.
+ */
+@RestrictTo(Scope.LIBRARY_GROUP)
+class GlanceMappedNode(private val mappedNode: MappedNode) : GlanceNode<MappedNode>(mappedNode) {
+
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    constructor(emittable: Emittable) : this(MappedNode(emittable))
+
+    override fun children(): List<GlanceNode<MappedNode>> {
+        val emittable = mappedNode.emittable
+        if (emittable is EmittableWithChildren) {
+            return emittable.children.map { child ->
+                GlanceMappedNode(child)
+            }
+        }
+        return emptyList()
+    }
+
+    override fun toDebugString(): String {
+        // TODO(b/201779038): map to a more readable format.
+        return mappedNode.emittable.toString()
+    }
+}
diff --git a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/AssertionErrorMessagesTest.kt b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/AssertionErrorMessagesTest.kt
new file mode 100644
index 0000000..e4bebe09
--- /dev/null
+++ b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/AssertionErrorMessagesTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.glance.testing
+
+import androidx.glance.testing.unit.GlanceMappedNode
+import androidx.glance.text.EmittableText
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class AssertionErrorMessagesTest {
+    @Test
+    fun countMismatch_expectedNone() {
+        val resultMessage = buildErrorMessageForCountMismatch(
+            errorMessage = "Failed assert",
+            matcherDescription = "testTag = 'my-node'",
+            expectedCount = 0,
+            actualCount = 1
+        )
+
+        assertThat(resultMessage).isEqualTo(
+            "Failed assert" +
+                "\nReason: Did not expect any node matching condition: testTag = 'my-node'" +
+                ", but found '1'"
+        )
+    }
+
+    @Test
+    fun countMismatch_expectedButFoundNone() {
+        val resultMessage = buildErrorMessageForCountMismatch(
+            errorMessage = "Failed assert",
+            matcherDescription = "testTag = 'my-node'",
+            expectedCount = 2,
+            actualCount = 0
+        )
+
+        assertThat(resultMessage).isEqualTo(
+            "Failed assert" +
+                "\nReason: Expected '2' node(s) matching condition: testTag = 'my-node'" +
+                ", but found '0'"
+        )
+    }
+
+    @Test
+    fun generalErrorMessage() {
+        val node = GlanceMappedNode(
+            EmittableText().also { it.text = "test text" }
+        )
+
+        val resultMessage = buildGeneralErrorMessage(
+            errorMessage = "Failed to match the condition: (testTag = 'my-node')",
+            glanceNode = node
+        )
+
+        assertThat(resultMessage).isEqualTo(
+            "Failed to match the condition: (testTag = 'my-node')" +
+                "\nGlance Node: ${node.toDebugString()}"
+        )
+    }
+}
diff --git a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/GlanceNodeAssertionTest.kt b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/GlanceNodeAssertionTest.kt
new file mode 100644
index 0000000..c27c769
--- /dev/null
+++ b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/GlanceNodeAssertionTest.kt
@@ -0,0 +1,278 @@
+/*
+ * 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.glance.testing
+
+import androidx.glance.GlanceModifier
+import androidx.glance.layout.EmittableColumn
+import androidx.glance.layout.EmittableSpacer
+import androidx.glance.semantics.semantics
+import androidx.glance.semantics.testTag
+import androidx.glance.testing.unit.GlanceMappedNode
+import androidx.glance.testing.unit.MappedNode
+import androidx.glance.testing.unit.hasTestTag
+import androidx.glance.testing.unit.hasText
+import androidx.glance.text.EmittableText
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+// Uses emittable based GlanceNode, matcher and filters to validate the logic in
+// GlanceNodeAssertion.
+class GlanceNodeAssertionTest {
+    @Test
+    fun assertExists_success() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode = GlanceMappedNode(
+            EmittableColumn().apply {
+                children.add(EmittableText().apply { text = "some text" })
+                children.add(EmittableSpacer())
+                children.add(EmittableText().apply {
+                    text = "another text"
+                    modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                })
+            }
+        )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        assertion.assertExists()
+        // no error
+    }
+
+    @Test
+    fun assertExists_error() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                EmittableColumn().apply {
+                    children.add(EmittableText().apply { text = "some text" })
+                    children.add(EmittableSpacer())
+                    children.add(EmittableText().apply {
+                        text = "another text"
+                        modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                    })
+                }
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "non-existing-test-tag"),
+            testContext = testContext
+        )
+
+        val assertionError = assertThrows(AssertionError::class.java) {
+            assertion.assertExists()
+        }
+
+        assertThat(assertionError).hasMessageThat().isEqualTo(
+            "Failed assertExists" +
+                "\nReason: Expected '1' node(s) matching condition: " +
+                "TestTag = 'non-existing-test-tag', but found '0'"
+        )
+    }
+
+    @Test
+    fun assertDoesNotExist_success() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                EmittableColumn().apply {
+                    children.add(EmittableText().apply { text = "some text" })
+                    children.add(EmittableSpacer())
+                    children.add(EmittableText().apply {
+                        text = "another text"
+                        modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                    })
+                }
+            )
+
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "non-existing-test-tag"),
+            testContext = testContext
+        )
+
+        assertion.assertDoesNotExist()
+        // no error
+    }
+
+    @Test
+    fun assertDoesNotExist_error() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                EmittableColumn().apply {
+                    children.add(EmittableText().apply { text = "some text" })
+                    children.add(EmittableSpacer())
+                    children.add(EmittableText().apply {
+                        text = "another text"
+                        modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                    })
+                }
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        val assertionError = assertThrows(AssertionError::class.java) {
+            assertion.assertDoesNotExist()
+        }
+
+        assertThat(assertionError).hasMessageThat().isEqualTo(
+            "Failed assertDoesNotExist" +
+                "\nReason: Did not expect any node matching condition: " +
+                "TestTag = 'existing-test-tag', but found '1'"
+        )
+    }
+
+    @Test
+    fun assert_withMatcher_success() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                EmittableColumn().apply {
+                    children.add(EmittableText().apply { text = "some text" })
+                    children.add(EmittableSpacer())
+                    children.add(EmittableText().apply {
+                        text = "another text"
+                        modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                    })
+                }
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        assertion.assert(hasText(text = "another text"))
+        // no error
+    }
+
+    @Test
+    fun chainAssertions() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                MappedNode(
+                    EmittableColumn().apply {
+                        children.add(EmittableText().apply { text = "some text" })
+                        children.add(EmittableSpacer())
+                        children.add(EmittableText().apply {
+                            text = "another text"
+                            modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                        })
+                    })
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        assertion
+            .assertExists()
+            .assert(hasText(text = "another text"))
+        // no error
+    }
+
+    @Test
+    fun chainAssertion_failureInFirst() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                MappedNode(
+                    EmittableColumn().apply {
+                        children.add(EmittableText().apply { text = "some text" })
+                        children.add(EmittableSpacer())
+                        children.add(EmittableText().apply {
+                            text = "another text"
+                            modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                        })
+                    }
+                )
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        val assertionError = assertThrows(AssertionError::class.java) {
+            assertion
+                .assertDoesNotExist()
+                .assert(hasText(text = "another text"))
+        }
+
+        assertThat(assertionError)
+            .hasMessageThat()
+            .isEqualTo(
+                "Failed assertDoesNotExist" +
+                    "\nReason: Did not expect any node matching condition: " +
+                    "TestTag = 'existing-test-tag', but found '1'"
+            )
+    }
+
+    @Test
+    fun chainAssertion_failureInSecond() {
+        val testContext = TestContext<MappedNode, GlanceMappedNode>()
+        // set root node of test tree to be traversed
+        testContext.rootGlanceNode =
+            GlanceMappedNode(
+                MappedNode(
+                    EmittableColumn().apply {
+                        children.add(EmittableText().apply { text = "some text" })
+                        children.add(EmittableSpacer())
+                        children.add(EmittableText().apply {
+                            text = "another text"
+                            modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                        })
+                    }
+                )
+            )
+        // This is the object that in real usage a rule.onNode(matcher) would return.
+        val assertion = GlanceNodeAssertion(
+            matcher = hasTestTag(testTag = "existing-test-tag"),
+            testContext = testContext
+        )
+
+        val assertionError = assertThrows(AssertionError::class.java) {
+            assertion
+                .assertExists()
+                .assert(hasText(text = "non-existing text"))
+        }
+
+        assertThat(assertionError)
+            .hasMessageThat()
+            .startsWith(
+                "Failed to assert condition: " +
+                    "(has text = 'non-existing text' (ignoreCase: 'false'))" +
+                    "\nGlance Node:"
+            )
+    }
+}
diff --git a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeFiltersAndMatcherTest.kt b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeFiltersAndMatcherTest.kt
new file mode 100644
index 0000000..2fb3c52
--- /dev/null
+++ b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeFiltersAndMatcherTest.kt
@@ -0,0 +1,207 @@
+/*
+ * 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.glance.testing.unit
+
+import androidx.glance.GlanceModifier
+import androidx.glance.layout.EmittableColumn
+import androidx.glance.semantics.semantics
+import androidx.glance.semantics.testTag
+import androidx.glance.text.EmittableText
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class GlanceMappedNodeFiltersAndMatcherTest {
+    @Test
+    fun matchAny_match_returnsTrue() {
+        val node1 = GlanceMappedNode(
+            EmittableText().apply {
+                text = "node1"
+            }
+        )
+        val node2 = GlanceMappedNode(
+            EmittableColumn().apply {
+                modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                EmittableText().apply { text = "node2" }
+            }
+        )
+
+        val result = hasTestTag("existing-test-tag").matchesAny(listOf(node1, node2))
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun matchAny_noMatch_returnsFalse() {
+        val node1 = GlanceMappedNode(
+            EmittableText().apply {
+                text = "node1"
+            }
+        )
+        val node2 = GlanceMappedNode(
+            EmittableColumn().apply {
+                EmittableText().apply {
+                    text = "node2"
+                    // this won't be inspected, as EmittableColumn node is being run against
+                    // matcher, not its children
+                    modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+                }
+            }
+        )
+
+        val result = hasTestTag("existing-test-tag").matchesAny(listOf(node1, node2))
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun hasTestTag_match_returnsTrue() {
+        // a single node that will be matched against matcher returned by the filter under test
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some text"
+                modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+            }
+        )
+
+        val result = hasTestTag("existing-test-tag").matches(testSingleNode)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun hasTestTag_noMatch_returnsFalse() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some text"
+                modifier = GlanceModifier.semantics { testTag = "existing-test-tag" }
+            }
+        )
+
+        val result = hasTestTag("non-existing-test-tag").matches(testSingleNode)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun hasText_match_returnsTrue() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "existing text"
+            }
+        )
+
+        val result = hasText("existing text").matches(testSingleNode)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun hasText_noMatch_returnsFalse() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "existing text"
+            }
+        )
+
+        val result = hasText("non-existing text").matches(testSingleNode)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun hasText_subStringMatch_returnsTrue() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some existing text"
+            }
+        )
+
+        val result = hasText(text = "existing", substring = true).matches(testSingleNode)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun hasText_subStringNoMatch_returnsFalse() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some existing text"
+            }
+        )
+
+        val result = hasText(text = "non-existing", substring = true).matches(testSingleNode)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun hasText_subStringCaseInsensitiveMatch_returnsTrue() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some EXISTING text"
+            }
+        )
+
+        val result =
+            hasText(text = "existing", substring = true, ignoreCase = true).matches(testSingleNode)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun hasText_subStringCaseInsensitiveNoMatch_returnsFalse() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some EXISTING text"
+            }
+        )
+
+        val result =
+            hasText(text = "non-EXISTING", substring = true, ignoreCase = true)
+                .matches(testSingleNode)
+
+        assertThat(result).isFalse()
+    }
+
+    @Test
+    fun hasText_caseInsensitiveMatch_returnsTrue() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some EXISTING text"
+            }
+        )
+
+        val result =
+            hasText(text = "SOME existing TEXT", ignoreCase = true).matches(testSingleNode)
+
+        assertThat(result).isTrue()
+    }
+
+    @Test
+    fun hasText_caseInsensitiveNoMatch_returnsFalse() {
+        val testSingleNode = GlanceMappedNode(
+            EmittableText().apply {
+                text = "some EXISTING text"
+            }
+        )
+
+        val result =
+            hasText(text = "SOME non-existing TEXT", ignoreCase = true).matches(testSingleNode)
+
+        assertThat(result).isFalse()
+    }
+}
diff --git a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeTest.kt b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeTest.kt
new file mode 100644
index 0000000..74eb14b
--- /dev/null
+++ b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/GlanceMappedNodeTest.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.glance.testing.unit
+
+import androidx.glance.layout.EmittableColumn
+import androidx.glance.layout.EmittableSpacer
+import androidx.glance.text.EmittableText
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class GlanceMappedNodeTest {
+    @Test
+    fun nodeChildren_returnsEmittableChildrenAsGlanceNodes() {
+        val childNode1 = EmittableText().apply { text = "some text" }
+        val childNode2 = EmittableSpacer()
+        val childNode3 = EmittableText().apply { text = "another text" }
+
+        val glanceNode = GlanceMappedNode(
+            EmittableColumn().apply {
+                children.add(childNode1)
+                children.add(childNode2)
+                children.add(childNode3)
+            }
+        )
+
+        assertThat(glanceNode.children()).containsExactly(
+            GlanceMappedNode(childNode1),
+            GlanceMappedNode(childNode2),
+            GlanceMappedNode(childNode3)
+        ).inOrder()
+    }
+
+    @Test
+    fun nodeChildren_noChildren_returnsEmptyList() {
+        val glanceNode = GlanceMappedNode(
+            EmittableColumn()
+        )
+
+        assertThat(glanceNode.children()).isEmpty()
+    }
+
+    @Test
+    fun nodeChildren_terminalEmittable_returnsEmptyList() {
+        val glanceNode = GlanceMappedNode(
+            EmittableText().apply { text = "test" }
+        )
+
+        assertThat(glanceNode.children()).isEmpty()
+    }
+}
diff --git a/gradle.properties b/gradle.properties
index 1e2cab4..50bdb04 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -5,6 +5,7 @@
 org.gradle.parallel=true
 org.gradle.caching=true
 org.gradle.welcome=never
+org.gradle.projectcachedir=../../out/gradle-project-cache
 # Disabled due to https://github.com/gradle/gradle/issues/18626
 # org.gradle.vfs.watch=true
 # Reenabled in gradlew, but disabled in Studio until these errors become shown (b/268380971) or computed more quickly (https://github.com/gradle/gradle/issues/23272)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d391eee..28950cc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -45,7 +45,7 @@
 kotlinCompileTesting = "1.4.9"
 kotlinCoroutines = "1.7.1"
 kotlinSerialization = "1.3.3"
-ksp = "1.9.0-1.0.11"
+ksp = "1.9.0-1.0.13"
 ktfmt = "0.44"
 ktlint = "0.49.1"
 leakcanary = "2.8.1"
diff --git a/gradlew b/gradlew
index d4e9c09..566a02d 100755
--- a/gradlew
+++ b/gradlew
@@ -382,6 +382,18 @@
   rm -rf $OUT_DIR
 }
 
+# Move any preexisting build scan to make room for a new one
+# After moving a build scan several times it eventually gets deleted
+function rotateBuildScans() {
+  filePrefix="$1"
+  iPlus1="10"
+  for i in $(seq 9 -1 1); do
+    mv "${filePrefix}.${i}.zip" "${filePrefix}.${iPlus1}.zip" 2>/dev/null || true
+    iPlus1=$i
+  done
+  mv ${filePrefix}.zip "${filePrefix}.1.zip" 2>/dev/null || true
+}
+
 function runGradle() {
   processOutput=false
   if [[ " ${@} " =~ " -Pandroidx.validateNoUnrecognizedMessages " ]]; then
@@ -397,14 +409,14 @@
   fi
 
   RETURN_VALUE=0
-  PROJECT_CACHE_DIR_ARGUMENT="--project-cache-dir $OUT_DIR/gradle-project-cache"
+  set -- "$@" -Dorg.gradle.projectcachedir="$OUT_DIR/gradle-project-cache"
   # Disabled in Studio until these errors become shown (b/268380971) or computed more quickly (https://github.com/gradle/gradle/issues/23272)
   if [[ " ${@} " =~ " --dependency-verification=" ]]; then
     VERIFICATION_ARGUMENT="" # already specified by caller
   else
     VERIFICATION_ARGUMENT=--dependency-verification=strict
   fi
-  if $wrapper "$JAVACMD" "${JVM_OPTS[@]}" $TMPDIR_ARG -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain $HOME_SYSTEM_PROPERTY_ARGUMENT $TMPDIR_ARG $PROJECT_CACHE_DIR_ARGUMENT $VERIFICATION_ARGUMENT "$ORG_GRADLE_JVMARGS" "$@"; then
+  if $wrapper "$JAVACMD" "${JVM_OPTS[@]}" $TMPDIR_ARG -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain $HOME_SYSTEM_PROPERTY_ARGUMENT $TMPDIR_ARG $VERIFICATION_ARGUMENT "$ORG_GRADLE_JVMARGS" "$@"; then
     RETURN_VALUE=0
   else
     # Print AndroidX-specific help message if build fails
@@ -422,11 +434,12 @@
       scanDir="$GRADLE_USER_HOME/build-scan-data"
       if [ -e "$scanDir" ]; then
         if [[ "$DISALLOW_TASK_EXECUTION" != "" ]]; then
-          zipPath="$DIST_DIR/scan-up-to-date.zip"
+          zipPrefix="$DIST_DIR/scan-up-to-date"
         else
-          zipPath="$DIST_DIR/scan.zip"
+          zipPrefix="$DIST_DIR/scan"
         fi
-        rm -f "$zipPath"
+        rotateBuildScans "$zipPrefix"
+        zipPath="${zipPrefix}.zip"
         cd "$GRADLE_USER_HOME/build-scan-data"
         zip -q -r "$zipPath" .
         cd -
diff --git a/graphics/graphics-shapes/api/current.txt b/graphics/graphics-shapes/api/current.txt
index 315aaf1..55829f0 100644
--- a/graphics/graphics-shapes/api/current.txt
+++ b/graphics/graphics-shapes/api/current.txt
@@ -16,18 +16,18 @@
 
   public final class Cubic {
     ctor public Cubic(androidx.graphics.shapes.Cubic cubic);
-    ctor public Cubic(float anchorX0, float anchorY0, float controlX0, float controlY0, float controlX1, float controlY1, float anchorX1, float anchorY1);
+    ctor public Cubic(float anchor0X, float anchor0Y, float control0X, float control0Y, float control1X, float control1Y, float anchor1X, float anchor1Y);
     method public static androidx.graphics.shapes.Cubic circularArc(float centerX, float centerY, float x0, float y0, float x1, float y1);
     method public operator androidx.graphics.shapes.Cubic div(float x);
     method public operator androidx.graphics.shapes.Cubic div(int x);
-    method public float getAnchorX0();
-    method public float getAnchorX1();
-    method public float getAnchorY0();
-    method public float getAnchorY1();
-    method public float getControlX0();
-    method public float getControlX1();
-    method public float getControlY0();
-    method public float getControlY1();
+    method public float getAnchor0X();
+    method public float getAnchor0Y();
+    method public float getAnchor1X();
+    method public float getAnchor1Y();
+    method public float getControl0X();
+    method public float getControl0Y();
+    method public float getControl1X();
+    method public float getControl1Y();
     method public static androidx.graphics.shapes.Cubic interpolate(androidx.graphics.shapes.Cubic start, androidx.graphics.shapes.Cubic end, float t);
     method public operator androidx.graphics.shapes.Cubic plus(androidx.graphics.shapes.Cubic o);
     method public android.graphics.PointF pointOnCurve(float t);
@@ -39,14 +39,14 @@
     method public operator androidx.graphics.shapes.Cubic times(int x);
     method public void transform(android.graphics.Matrix matrix);
     method public void transform(android.graphics.Matrix matrix, optional float[] points);
-    property public final float anchorX0;
-    property public final float anchorX1;
-    property public final float anchorY0;
-    property public final float anchorY1;
-    property public final float controlX0;
-    property public final float controlX1;
-    property public final float controlY0;
-    property public final float controlY1;
+    property public final float anchor0X;
+    property public final float anchor0Y;
+    property public final float anchor1X;
+    property public final float anchor1Y;
+    property public final float control0X;
+    property public final float control0Y;
+    property public final float control1X;
+    property public final float control1Y;
     field public static final androidx.graphics.shapes.Cubic.Companion Companion;
   }
 
diff --git a/graphics/graphics-shapes/api/restricted_current.txt b/graphics/graphics-shapes/api/restricted_current.txt
index 315aaf1..55829f0 100644
--- a/graphics/graphics-shapes/api/restricted_current.txt
+++ b/graphics/graphics-shapes/api/restricted_current.txt
@@ -16,18 +16,18 @@
 
   public final class Cubic {
     ctor public Cubic(androidx.graphics.shapes.Cubic cubic);
-    ctor public Cubic(float anchorX0, float anchorY0, float controlX0, float controlY0, float controlX1, float controlY1, float anchorX1, float anchorY1);
+    ctor public Cubic(float anchor0X, float anchor0Y, float control0X, float control0Y, float control1X, float control1Y, float anchor1X, float anchor1Y);
     method public static androidx.graphics.shapes.Cubic circularArc(float centerX, float centerY, float x0, float y0, float x1, float y1);
     method public operator androidx.graphics.shapes.Cubic div(float x);
     method public operator androidx.graphics.shapes.Cubic div(int x);
-    method public float getAnchorX0();
-    method public float getAnchorX1();
-    method public float getAnchorY0();
-    method public float getAnchorY1();
-    method public float getControlX0();
-    method public float getControlX1();
-    method public float getControlY0();
-    method public float getControlY1();
+    method public float getAnchor0X();
+    method public float getAnchor0Y();
+    method public float getAnchor1X();
+    method public float getAnchor1Y();
+    method public float getControl0X();
+    method public float getControl0Y();
+    method public float getControl1X();
+    method public float getControl1Y();
     method public static androidx.graphics.shapes.Cubic interpolate(androidx.graphics.shapes.Cubic start, androidx.graphics.shapes.Cubic end, float t);
     method public operator androidx.graphics.shapes.Cubic plus(androidx.graphics.shapes.Cubic o);
     method public android.graphics.PointF pointOnCurve(float t);
@@ -39,14 +39,14 @@
     method public operator androidx.graphics.shapes.Cubic times(int x);
     method public void transform(android.graphics.Matrix matrix);
     method public void transform(android.graphics.Matrix matrix, optional float[] points);
-    property public final float anchorX0;
-    property public final float anchorX1;
-    property public final float anchorY0;
-    property public final float anchorY1;
-    property public final float controlX0;
-    property public final float controlX1;
-    property public final float controlY0;
-    property public final float controlY1;
+    property public final float anchor0X;
+    property public final float anchor0Y;
+    property public final float anchor1X;
+    property public final float anchor1Y;
+    property public final float control0X;
+    property public final float control0Y;
+    property public final float control1X;
+    property public final float control1Y;
     field public static final androidx.graphics.shapes.Cubic.Companion Companion;
   }
 
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicShapeTest.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicShapeTest.kt
index 2658b26..422b636 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicShapeTest.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicShapeTest.kt
@@ -43,8 +43,8 @@
     val cubic1 = Cubic(point4, point5, point6, point7)
 
     fun getClosingCubic(first: Cubic, last: Cubic): Cubic {
-        return Cubic(last.anchorX1, last.anchorY1, last.anchorX1, last.anchorY1,
-            first.anchorX0, first.anchorY0, first.anchorX0, first.anchorY0)
+        return Cubic(last.anchor1X, last.anchor1Y, last.anchor1X, last.anchor1Y,
+            first.anchor0X, first.anchor0Y, first.anchor0X, first.anchor0Y)
     }
 
     @Test
@@ -101,12 +101,12 @@
         shape.transform(translator)
         val cubic = shape.cubics[0]
         assertPointsEqualish(PointF(translatedPoints[0], translatedPoints[1]),
-            PointF(cubic.anchorX0, cubic.anchorY0))
+            PointF(cubic.anchor0X, cubic.anchor0Y))
         assertPointsEqualish(PointF(translatedPoints[2], translatedPoints[3]),
-            PointF(cubic.controlX0, cubic.controlY0))
+            PointF(cubic.control0X, cubic.control0Y))
         assertPointsEqualish(PointF(translatedPoints[4], translatedPoints[5]),
-            PointF(cubic.controlX1, cubic.controlY1))
+            PointF(cubic.control1X, cubic.control1Y))
         assertPointsEqualish(PointF(translatedPoints[6], translatedPoints[7]),
-            PointF(cubic.anchorX1, cubic.anchorY1))
+            PointF(cubic.anchor1X, cubic.anchor1Y))
     }
 }
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicTest.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicTest.kt
index 5102900..78b58c2 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicTest.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/CubicTest.kt
@@ -41,34 +41,34 @@
 
     @Test
     fun constructionTest() {
-        assertEquals(p0, PointF(cubic.anchorX0, cubic.anchorY0))
-        assertEquals(p1, PointF(cubic.controlX0, cubic.controlY0))
-        assertEquals(p2, PointF(cubic.controlX1, cubic.controlY1))
-        assertEquals(p3, PointF(cubic.anchorX1, cubic.anchorY1))
+        assertEquals(p0, PointF(cubic.anchor0X, cubic.anchor0Y))
+        assertEquals(p1, PointF(cubic.control0X, cubic.control0Y))
+        assertEquals(p2, PointF(cubic.control1X, cubic.control1Y))
+        assertEquals(p3, PointF(cubic.anchor1X, cubic.anchor1Y))
     }
 
     @Test
     fun copyTest() {
         val copy = Cubic(cubic)
-        assertEquals(p0, PointF(copy.anchorX0, copy.anchorY0))
-        assertEquals(p1, PointF(copy.controlX0, copy.controlY0))
-        assertEquals(p2, PointF(copy.controlX1, copy.controlY1))
-        assertEquals(p3, PointF(copy.anchorX1, copy.anchorY1))
-        assertEquals(PointF(cubic.anchorX0, cubic.anchorY0),
-            PointF(copy.anchorX0, copy.anchorY0))
-        assertEquals(PointF(cubic.controlX0, cubic.controlY0),
-            PointF(copy.controlX0, copy.controlY0))
-        assertEquals(PointF(cubic.controlX1, cubic.controlY1),
-            PointF(copy.controlX1, copy.controlY1))
-        assertEquals(PointF(cubic.anchorX1, cubic.anchorY1),
-            PointF(copy.anchorX1, copy.anchorY1))
+        assertEquals(p0, PointF(copy.anchor0X, copy.anchor0Y))
+        assertEquals(p1, PointF(copy.control0X, copy.control0Y))
+        assertEquals(p2, PointF(copy.control1X, copy.control1Y))
+        assertEquals(p3, PointF(copy.anchor1X, copy.anchor1Y))
+        assertEquals(PointF(cubic.anchor0X, cubic.anchor0Y),
+            PointF(copy.anchor0X, copy.anchor0Y))
+        assertEquals(PointF(cubic.control0X, cubic.control0Y),
+            PointF(copy.control0X, copy.control0Y))
+        assertEquals(PointF(cubic.control1X, cubic.control1Y),
+            PointF(copy.control1X, copy.control1Y))
+        assertEquals(PointF(cubic.anchor1X, cubic.anchor1Y),
+            PointF(copy.anchor1X, copy.anchor1Y))
     }
 
     @Test
     fun circularArcTest() {
         val arcCubic = Cubic.circularArc(zero.x, zero.y, p0.x, p0.y, p3.x, p3.y)
-        assertEquals(p0, PointF(arcCubic.anchorX0, arcCubic.anchorY0))
-        assertEquals(p3, PointF(arcCubic.anchorX1, arcCubic.anchorY1))
+        assertEquals(p0, PointF(arcCubic.anchor0X, arcCubic.anchor0Y))
+        assertEquals(p3, PointF(arcCubic.anchor1X, arcCubic.anchor1Y))
     }
 
     @Test
@@ -78,62 +78,62 @@
         divCubic = cubic / 1
         assertCubicsEqua1ish(cubic, divCubic)
         divCubic = cubic / 2f
-        assertPointsEqualish(p0 / 2f, PointF(divCubic.anchorX0, divCubic.anchorY0))
-        assertPointsEqualish(p1 / 2f, PointF(divCubic.controlX0, divCubic.controlY0))
-        assertPointsEqualish(p2 / 2f, PointF(divCubic.controlX1, divCubic.controlY1))
-        assertPointsEqualish(p3 / 2f, PointF(divCubic.anchorX1, divCubic.anchorY1))
+        assertPointsEqualish(p0 / 2f, PointF(divCubic.anchor0X, divCubic.anchor0Y))
+        assertPointsEqualish(p1 / 2f, PointF(divCubic.control0X, divCubic.control0Y))
+        assertPointsEqualish(p2 / 2f, PointF(divCubic.control1X, divCubic.control1Y))
+        assertPointsEqualish(p3 / 2f, PointF(divCubic.anchor1X, divCubic.anchor1Y))
         divCubic = cubic / 2
-        assertPointsEqualish(p0 / 2f, PointF(divCubic.anchorX0, divCubic.anchorY0))
-        assertPointsEqualish(p1 / 2f, PointF(divCubic.controlX0, divCubic.controlY0))
-        assertPointsEqualish(p2 / 2f, PointF(divCubic.controlX1, divCubic.controlY1))
-        assertPointsEqualish(p3 / 2f, PointF(divCubic.anchorX1, divCubic.anchorY1))
+        assertPointsEqualish(p0 / 2f, PointF(divCubic.anchor0X, divCubic.anchor0Y))
+        assertPointsEqualish(p1 / 2f, PointF(divCubic.control0X, divCubic.control0Y))
+        assertPointsEqualish(p2 / 2f, PointF(divCubic.control1X, divCubic.control1Y))
+        assertPointsEqualish(p3 / 2f, PointF(divCubic.anchor1X, divCubic.anchor1Y))
     }
 
     @Test
     fun timesTest() {
         var timesCubic = cubic * 1f
-        assertEquals(p0, PointF(timesCubic.anchorX0, timesCubic.anchorY0))
-        assertEquals(p1, PointF(timesCubic.controlX0, timesCubic.controlY0))
-        assertEquals(p2, PointF(timesCubic.controlX1, timesCubic.controlY1))
-        assertEquals(p3, PointF(timesCubic.anchorX1, timesCubic.anchorY1))
+        assertEquals(p0, PointF(timesCubic.anchor0X, timesCubic.anchor0Y))
+        assertEquals(p1, PointF(timesCubic.control0X, timesCubic.control0Y))
+        assertEquals(p2, PointF(timesCubic.control1X, timesCubic.control1Y))
+        assertEquals(p3, PointF(timesCubic.anchor1X, timesCubic.anchor1Y))
         timesCubic = cubic * 1
-        assertEquals(p0, PointF(timesCubic.anchorX0, timesCubic.anchorY0))
-        assertEquals(p1, PointF(timesCubic.controlX0, timesCubic.controlY0))
-        assertEquals(p2, PointF(timesCubic.controlX1, timesCubic.controlY1))
-        assertEquals(p3, PointF(timesCubic.anchorX1, timesCubic.anchorY1))
+        assertEquals(p0, PointF(timesCubic.anchor0X, timesCubic.anchor0Y))
+        assertEquals(p1, PointF(timesCubic.control0X, timesCubic.control0Y))
+        assertEquals(p2, PointF(timesCubic.control1X, timesCubic.control1Y))
+        assertEquals(p3, PointF(timesCubic.anchor1X, timesCubic.anchor1Y))
         timesCubic = cubic * 2f
-        assertPointsEqualish(p0 * 2f, PointF(timesCubic.anchorX0, timesCubic.anchorY0))
-        assertPointsEqualish(p1 * 2f, PointF(timesCubic.controlX0, timesCubic.controlY0))
-        assertPointsEqualish(p2 * 2f, PointF(timesCubic.controlX1, timesCubic.controlY1))
-        assertPointsEqualish(p3 * 2f, PointF(timesCubic.anchorX1, timesCubic.anchorY1))
+        assertPointsEqualish(p0 * 2f, PointF(timesCubic.anchor0X, timesCubic.anchor0Y))
+        assertPointsEqualish(p1 * 2f, PointF(timesCubic.control0X, timesCubic.control0Y))
+        assertPointsEqualish(p2 * 2f, PointF(timesCubic.control1X, timesCubic.control1Y))
+        assertPointsEqualish(p3 * 2f, PointF(timesCubic.anchor1X, timesCubic.anchor1Y))
         timesCubic = cubic * 2
-        assertPointsEqualish(p0 * 2f, PointF(timesCubic.anchorX0, timesCubic.anchorY0))
-        assertPointsEqualish(p1 * 2f, PointF(timesCubic.controlX0, timesCubic.controlY0))
-        assertPointsEqualish(p2 * 2f, PointF(timesCubic.controlX1, timesCubic.controlY1))
-        assertPointsEqualish(p3 * 2f, PointF(timesCubic.anchorX1, timesCubic.anchorY1))
+        assertPointsEqualish(p0 * 2f, PointF(timesCubic.anchor0X, timesCubic.anchor0Y))
+        assertPointsEqualish(p1 * 2f, PointF(timesCubic.control0X, timesCubic.control0Y))
+        assertPointsEqualish(p2 * 2f, PointF(timesCubic.control1X, timesCubic.control1Y))
+        assertPointsEqualish(p3 * 2f, PointF(timesCubic.anchor1X, timesCubic.anchor1Y))
     }
 
     @Test
     fun plusTest() {
         val offsetCubic = cubic * 2f
         var plusCubic = cubic + offsetCubic
-        assertPointsEqualish(p0 + PointF(offsetCubic.anchorX0, offsetCubic.anchorY0),
-            PointF(plusCubic.anchorX0, plusCubic.anchorY0))
-        assertPointsEqualish(p1 + PointF(offsetCubic.controlX0, offsetCubic.controlY0),
-            PointF(plusCubic.controlX0, plusCubic.controlY0))
-        assertPointsEqualish(p2 + PointF(offsetCubic.controlX1, offsetCubic.controlY1),
-            PointF(plusCubic.controlX1, plusCubic.controlY1))
-        assertPointsEqualish(p3 + PointF(offsetCubic.anchorX1, offsetCubic.anchorY1),
-            PointF(plusCubic.anchorX1, plusCubic.anchorY1))
+        assertPointsEqualish(p0 + PointF(offsetCubic.anchor0X, offsetCubic.anchor0Y),
+            PointF(plusCubic.anchor0X, plusCubic.anchor0Y))
+        assertPointsEqualish(p1 + PointF(offsetCubic.control0X, offsetCubic.control0Y),
+            PointF(plusCubic.control0X, plusCubic.control0Y))
+        assertPointsEqualish(p2 + PointF(offsetCubic.control1X, offsetCubic.control1Y),
+            PointF(plusCubic.control1X, plusCubic.control1Y))
+        assertPointsEqualish(p3 + PointF(offsetCubic.anchor1X, offsetCubic.anchor1Y),
+            PointF(plusCubic.anchor1X, plusCubic.anchor1Y))
     }
 
     @Test
     fun reverseTest() {
         val reverseCubic = cubic.reverse()
-        assertEquals(p3, PointF(reverseCubic.anchorX0, reverseCubic.anchorY0))
-        assertEquals(p2, PointF(reverseCubic.controlX0, reverseCubic.controlY0))
-        assertEquals(p1, PointF(reverseCubic.controlX1, reverseCubic.controlY1))
-        assertEquals(p0, PointF(reverseCubic.anchorX1, reverseCubic.anchorY1))
+        assertEquals(p3, PointF(reverseCubic.anchor0X, reverseCubic.anchor0Y))
+        assertEquals(p2, PointF(reverseCubic.control0X, reverseCubic.control0Y))
+        assertEquals(p1, PointF(reverseCubic.control1X, reverseCubic.control1Y))
+        assertEquals(p0, PointF(reverseCubic.anchor1X, reverseCubic.anchor1Y))
     }
 
     fun assertBetween(end0: PointF, end1: PointF, actual: PointF) {
@@ -150,10 +150,10 @@
     @Test
     fun straightLineTest() {
         val lineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y)
-        assertEquals(p0, PointF(lineCubic.anchorX0, lineCubic.anchorY0))
-        assertEquals(p3, PointF(lineCubic.anchorX1, lineCubic.anchorY1))
-        assertBetween(p0, p3, PointF(lineCubic.controlX0, lineCubic.controlY0))
-        assertBetween(p0, p3, PointF(lineCubic.controlX1, lineCubic.controlY1))
+        assertEquals(p0, PointF(lineCubic.anchor0X, lineCubic.anchor0Y))
+        assertEquals(p3, PointF(lineCubic.anchor1X, lineCubic.anchor1Y))
+        assertBetween(p0, p3, PointF(lineCubic.control0X, lineCubic.control0Y))
+        assertBetween(p0, p3, PointF(lineCubic.control1X, lineCubic.control1Y))
     }
 
     @Test
@@ -167,23 +167,23 @@
     @Test
     fun splitTest() {
         val (split0, split1) = cubic.split(.5f)
-        assertEquals(PointF(cubic.anchorX0, cubic.anchorY0),
-            PointF(split0.anchorX0, split0.anchorY0))
-        assertEquals(PointF(cubic.anchorX1, cubic.anchorY1),
-            PointF(split1.anchorX1, split1.anchorY1))
-        assertBetween(PointF(cubic.anchorX0, cubic.anchorY0),
-            PointF(cubic.anchorX1, cubic.anchorY1),
-            PointF(split0.anchorX1, split0.anchorY1))
-        assertBetween(PointF(cubic.anchorX0, cubic.anchorY0),
-            PointF(cubic.anchorX1, cubic.anchorY1),
-            PointF(split1.anchorX0, split1.anchorY0))
+        assertEquals(PointF(cubic.anchor0X, cubic.anchor0Y),
+            PointF(split0.anchor0X, split0.anchor0Y))
+        assertEquals(PointF(cubic.anchor1X, cubic.anchor1Y),
+            PointF(split1.anchor1X, split1.anchor1Y))
+        assertBetween(PointF(cubic.anchor0X, cubic.anchor0Y),
+            PointF(cubic.anchor1X, cubic.anchor1Y),
+            PointF(split0.anchor1X, split0.anchor1Y))
+        assertBetween(PointF(cubic.anchor0X, cubic.anchor0Y),
+            PointF(cubic.anchor1X, cubic.anchor1Y),
+            PointF(split1.anchor0X, split1.anchor0Y))
     }
 
     @Test
     fun pointOnCurveTest() {
         var halfway = cubic.pointOnCurve(.5f)
-        assertBetween(PointF(cubic.anchorX0, cubic.anchorY0),
-            PointF(cubic.anchorX1, cubic.anchorY1), halfway)
+        assertBetween(PointF(cubic.anchor0X, cubic.anchor0Y),
+            PointF(cubic.anchor1X, cubic.anchor1Y), halfway)
         val straightLineCubic = Cubic.straightLine(p0.x, p0.y, p3.x, p3.y)
         halfway = straightLineCubic.pointOnCurve(.5f)
         val computedHalfway = PointF(p0.x + .5f * (p3.x - p0.x), p0.y + .5f * (p3.y - p0.y))
@@ -208,13 +208,13 @@
         transformedCubic = Cubic(cubic)
         matrix.setTranslate(tx, ty)
         transformedCubic.transform(matrix)
-        assertPointsEqualish(PointF(cubic.anchorX0, cubic.anchorY0) + translationVector,
-            PointF(transformedCubic.anchorX0, transformedCubic.anchorY0))
-        assertPointsEqualish(PointF(cubic.controlX0, cubic.controlY0) + translationVector,
-            PointF(transformedCubic.controlX0, transformedCubic.controlY0))
-        assertPointsEqualish(PointF(cubic.controlX1, cubic.controlY1) + translationVector,
-            PointF(transformedCubic.controlX1, transformedCubic.controlY1))
-        assertPointsEqualish(PointF(cubic.anchorX1, cubic.anchorY1) + translationVector,
-            PointF(transformedCubic.anchorX1, transformedCubic.anchorY1))
+        assertPointsEqualish(PointF(cubic.anchor0X, cubic.anchor0Y) + translationVector,
+            PointF(transformedCubic.anchor0X, transformedCubic.anchor0Y))
+        assertPointsEqualish(PointF(cubic.control0X, cubic.control0Y) + translationVector,
+            PointF(transformedCubic.control0X, transformedCubic.control0Y))
+        assertPointsEqualish(PointF(cubic.control1X, cubic.control1Y) + translationVector,
+            PointF(transformedCubic.control1X, transformedCubic.control1Y))
+        assertPointsEqualish(PointF(cubic.anchor1X, cubic.anchor1Y) + translationVector,
+            PointF(transformedCubic.anchor1X, transformedCubic.anchor1Y))
     }
 }
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/PolygonTest.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/PolygonTest.kt
index c6dd7b7..cb3780e 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/PolygonTest.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/PolygonTest.kt
@@ -116,18 +116,18 @@
         val squareCubics = square.toCubicShape().cubics
         val squareCopyCubics = squareCopy.toCubicShape().cubics
         for (i in 0 until squareCubics.size) {
-            assertPointsEqualish(PointF(squareCopyCubics[i].anchorX0,
-                squareCopyCubics[i].anchorY0) + offset,
-                PointF(squareCubics[i].anchorX0, squareCubics[i].anchorY0))
-            assertPointsEqualish(PointF(squareCopyCubics[i].controlX0,
-                squareCopyCubics[i].controlY0) + offset,
-                PointF(squareCubics[i].controlX0, squareCubics[i].controlY0))
-            assertPointsEqualish(PointF(squareCopyCubics[i].controlX1,
-                squareCopyCubics[i].controlY1) + offset,
-                PointF(squareCubics[i].controlX1, squareCubics[i].controlY1))
-            assertPointsEqualish(PointF(squareCopyCubics[i].anchorX1,
-                squareCopyCubics[i].anchorY1) + offset,
-                PointF(squareCubics[i].anchorX1, squareCubics[i].anchorY1))
+            assertPointsEqualish(PointF(squareCopyCubics[i].anchor0X,
+                squareCopyCubics[i].anchor0Y) + offset,
+                PointF(squareCubics[i].anchor0X, squareCubics[i].anchor0Y))
+            assertPointsEqualish(PointF(squareCopyCubics[i].control0X,
+                squareCopyCubics[i].control0Y) + offset,
+                PointF(squareCubics[i].control0X, squareCubics[i].control0Y))
+            assertPointsEqualish(PointF(squareCopyCubics[i].control1X,
+                squareCopyCubics[i].control1Y) + offset,
+                PointF(squareCubics[i].control1X, squareCubics[i].control1Y))
+            assertPointsEqualish(PointF(squareCopyCubics[i].anchor1X,
+                squareCopyCubics[i].anchor1Y) + offset,
+                PointF(squareCubics[i].anchor1X, squareCubics[i].anchor1Y))
         }
     }
 
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/RoundedPolygonTest.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/RoundedPolygonTest.kt
index 74c2569..0df251f 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/RoundedPolygonTest.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/RoundedPolygonTest.kt
@@ -126,10 +126,10 @@
         assertEquals(1, lowerEdgeFeature.cubics.size)
 
         val lowerEdge = lowerEdgeFeature.cubics.first()
-        assertEqualish(0.5f, lowerEdge.anchorX0)
-        assertEqualish(0.0f, lowerEdge.anchorY0)
-        assertEqualish(0.5f, lowerEdge.anchorX1)
-        assertEqualish(0.0f, lowerEdge.anchorY1)
+        assertEqualish(0.5f, lowerEdge.anchor0X)
+        assertEqualish(0.0f, lowerEdge.anchor0Y)
+        assertEqualish(0.5f, lowerEdge.anchor1X)
+        assertEqualish(0.0f, lowerEdge.anchor1Y)
     }
 
     /*
@@ -228,9 +228,9 @@
         )
         val (e01, _, _, e30) = polygon.features.filterIsInstance<RoundedPolygon.Edge>()
         val msg = "r0 = ${show(rounding0)}, r3 = ${show(rounding3)}"
-        assertEqualish(expectedV0SX, e01.cubics.first().anchorX0, msg)
-        assertEqualish(expectedV0SY, e30.cubics.first().anchorY1, msg)
-        assertEqualish(expectedV3SY, 1f - e30.cubics.first().anchorY0, msg)
+        assertEqualish(expectedV0SX, e01.cubics.first().anchor0X, msg)
+        assertEqualish(expectedV0SY, e30.cubics.first().anchor1Y, msg)
+        assertEqualish(expectedV3SY, 1f - e30.cubics.first().anchor0Y, msg)
     }
 
     private fun show(cr: CornerRounding) = "(r=${cr.radius}, s=${cr.smoothing})"
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/ShapesTest.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/ShapesTest.kt
index 21480b6..34abff59 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/ShapesTest.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/ShapesTest.kt
@@ -64,8 +64,8 @@
         radius2: Float = radius1,
         center: PointF = Zero
     ) {
-        assertPointOnRadii(PointF(cubic.anchorX0, cubic.anchorY0), radius1, radius2, center)
-        assertPointOnRadii(PointF(cubic.anchorX1, cubic.anchorY1), radius1, radius2, center)
+        assertPointOnRadii(PointF(cubic.anchor0X, cubic.anchor0Y), radius1, radius2, center)
+        assertPointOnRadii(PointF(cubic.anchor1X, cubic.anchor1Y), radius1, radius2, center)
     }
 
     /**
diff --git a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/TestUtils.kt b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/TestUtils.kt
index 95cc411..1cce6ad6 100644
--- a/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/TestUtils.kt
+++ b/graphics/graphics-shapes/src/androidTest/java/androidx/graphics/shapes/TestUtils.kt
@@ -29,14 +29,14 @@
 }
 
 fun assertCubicsEqua1ish(expected: Cubic, actual: Cubic) {
-    assertPointsEqualish(PointF(expected.anchorX0, expected.anchorY0),
-        PointF(actual.anchorX0, actual.anchorY0))
-    assertPointsEqualish(PointF(expected.controlX0, expected.controlY0),
-        PointF(actual.controlX0, actual.controlY0))
-    assertPointsEqualish(PointF(expected.controlX1, expected.controlY1),
-        PointF(actual.controlX1, actual.controlY1))
-    assertPointsEqualish(PointF(expected.anchorX1, expected.anchorY1),
-        PointF(actual.anchorX1, actual.anchorY1))
+    assertPointsEqualish(PointF(expected.anchor0X, expected.anchor0Y),
+        PointF(actual.anchor0X, actual.anchor0Y))
+    assertPointsEqualish(PointF(expected.control0X, expected.control0Y),
+        PointF(actual.control0X, actual.control0Y))
+    assertPointsEqualish(PointF(expected.control1X, expected.control1Y),
+        PointF(actual.control1X, actual.control1Y))
+    assertPointsEqualish(PointF(expected.anchor1X, expected.anchor1Y),
+        PointF(actual.anchor1X, actual.anchor1Y))
 }
 
 fun assertPointGreaterish(expected: PointF, actual: PointF) {
@@ -56,13 +56,13 @@
 fun assertInBounds(shape: CubicShape, minPoint: PointF, maxPoint: PointF) {
     val cubics = shape.cubics
     for (cubic in cubics) {
-        assertPointGreaterish(minPoint, PointF(cubic.anchorX0, cubic.anchorY0))
-        assertPointLessish(maxPoint, PointF(cubic.anchorX0, cubic.anchorY0))
-        assertPointGreaterish(minPoint, PointF(cubic.controlX0, cubic.controlY0))
-        assertPointLessish(maxPoint, PointF(cubic.controlX0, cubic.controlY0))
-        assertPointGreaterish(minPoint, PointF(cubic.controlX1, cubic.controlY1))
-        assertPointLessish(maxPoint, PointF(cubic.controlX1, cubic.controlY1))
-        assertPointGreaterish(minPoint, PointF(cubic.anchorX1, cubic.anchorY1))
-        assertPointLessish(maxPoint, PointF(cubic.anchorX1, cubic.anchorY1))
+        assertPointGreaterish(minPoint, PointF(cubic.anchor0X, cubic.anchor0Y))
+        assertPointLessish(maxPoint, PointF(cubic.anchor0X, cubic.anchor0Y))
+        assertPointGreaterish(minPoint, PointF(cubic.control0X, cubic.control0Y))
+        assertPointLessish(maxPoint, PointF(cubic.control0X, cubic.control0Y))
+        assertPointGreaterish(minPoint, PointF(cubic.control1X, cubic.control1Y))
+        assertPointLessish(maxPoint, PointF(cubic.control1X, cubic.control1Y))
+        assertPointGreaterish(minPoint, PointF(cubic.anchor1X, cubic.anchor1Y))
+        assertPointLessish(maxPoint, PointF(cubic.anchor1X, cubic.anchor1Y))
     }
 }
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Cubic.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Cubic.kt
index ca95d66..a99d457 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Cubic.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Cubic.kt
@@ -22,76 +22,76 @@
 
 /**
  * This class holds the anchor and control point data for a single cubic Bézier curve,
- * with anchor points ([anchorX0], [anchorY0]) and ([anchorX1], [anchorY1]) at either end
- * and control points ([controlX0], [controlY0]) and ([controlX1], [controlY1]) determining
+ * with anchor points ([anchor0X], [anchor0Y]) and ([anchor1X], [anchor1Y]) at either end
+ * and control points ([control0X], [control0Y]) and ([control1X], [control1Y]) determining
  * the slope of the curve between the anchor points.
  *
- * @param anchorX0 the first anchor point x coordinate
- * @param anchorY0 the first anchor point y coordinate
- * @param controlX0 the first control point x coordinate
- * @param controlY0 the first control point y coordinate
- * @param controlX1 the second control point x coordinate
- * @param controlY1 the second control point y coordinate
- * @param anchorX1 the second anchor point x coordinate
- * @param anchorY1 the second anchor point y coordinate
+ * @param anchor0X the first anchor point x coordinate
+ * @param anchor0Y the first anchor point y coordinate
+ * @param control0X the first control point x coordinate
+ * @param control0Y the first control point y coordinate
+ * @param control1X the second control point x coordinate
+ * @param control1Y the second control point y coordinate
+ * @param anchor1X the second anchor point x coordinate
+ * @param anchor1Y the second anchor point y coordinate
  */
 class Cubic(
-    anchorX0: Float,
-    anchorY0: Float,
-    controlX0: Float,
-    controlY0: Float,
-    controlX1: Float,
-    controlY1: Float,
-    anchorX1: Float,
-    anchorY1: Float
+    anchor0X: Float,
+    anchor0Y: Float,
+    control0X: Float,
+    control0Y: Float,
+    control1X: Float,
+    control1Y: Float,
+    anchor1X: Float,
+    anchor1Y: Float
 ) {
 
     /**
      * The first anchor point x coordinate
      */
-    var anchorX0: Float = anchorX0
+    var anchor0X: Float = anchor0X
         private set
 
     /**
      * The first anchor point y coordinate
      */
-    var anchorY0: Float = anchorY0
+    var anchor0Y: Float = anchor0Y
         private set
 
     /**
      * The first control point x coordinate
      */
-    var controlX0: Float = controlX0
+    var control0X: Float = control0X
         private set
 
     /**
      * The first control point y coordinate
      */
-    var controlY0: Float = controlY0
+    var control0Y: Float = control0Y
         private set
 
     /**
      * The second control point x coordinate
      */
-    var controlX1: Float = controlX1
+    var control1X: Float = control1X
         private set
 
     /**
      * The second control point y coordinate
      */
-    var controlY1: Float = controlY1
+    var control1Y: Float = control1Y
         private set
 
     /**
      * The second anchor point x coordinate
      */
-    var anchorX1: Float = anchorX1
+    var anchor1X: Float = anchor1X
         private set
 
     /**
      * The second anchor point y coordinate
      */
-    var anchorY1: Float = anchorY1
+    var anchor1Y: Float = anchor1Y
         private set
 
     internal constructor(p0: PointF, p1: PointF, p2: PointF, p3: PointF) :
@@ -101,32 +101,32 @@
      * Copy constructor which creates a copy of the given object.
      */
     constructor(cubic: Cubic) : this(
-        cubic.anchorX0, cubic.anchorY0, cubic.controlX0, cubic.controlY0,
-        cubic.controlX1, cubic.controlY1, cubic.anchorX1, cubic.anchorY1,
+        cubic.anchor0X, cubic.anchor0Y, cubic.control0X, cubic.control0Y,
+        cubic.control1X, cubic.control1Y, cubic.anchor1X, cubic.anchor1Y,
     )
 
     override fun toString(): String {
-        return "p0: ($anchorX0, $anchorY0) p1: ($controlX0, $controlY0), " +
-            "p2: ($controlX1, $controlY1), p3: ($anchorX1, $anchorY1)"
+        return "p0: ($anchor0X, $anchor0Y) p1: ($control0X, $control0Y), " +
+            "p2: ($control1X, $control1Y), p3: ($anchor1X, $anchor1Y)"
     }
 
     /**
      * Returns a point on the curve for parameter t, representing the proportional distance
-     * along the curve between its starting ([anchorX0], [anchorY0]) and ending
-     * ([anchorX1], [anchorY1]) anchor points.
+     * along the curve between its starting ([anchor0X], [anchor0Y]) and ending
+     * ([anchor1X], [anchor1Y]) anchor points.
      *
      * @param t The distance along the curve between the anchor points, where 0 is at
-     * ([anchorX0], [anchorY0]) and 1 is at ([controlX0], [controlY0])
+     * ([anchor0X], [anchor0Y]) and 1 is at ([control0X], [control0Y])
      * @param result Optional object to hold the result, can be passed in to avoid allocating a
      * new PointF object.
      */
     @JvmOverloads
     fun pointOnCurve(t: Float, result: PointF = PointF()): PointF {
         val u = 1 - t
-        result.x = anchorX0 * (u * u * u) + controlX0 * (3 * t * u * u) +
-            controlX1 * (3 * t * t * u) + anchorX1 * (t * t * t)
-        result.y = anchorY0 * (u * u * u) + controlY0 * (3 * t * u * u) +
-            controlY1 * (3 * t * t * u) + anchorY1 * (t * t * t)
+        result.x = anchor0X * (u * u * u) + control0X * (3 * t * u * u) +
+            control1X * (3 * t * t * u) + anchor1X * (t * t * t)
+        result.y = anchor0Y * (u * u * u) + control0Y * (3 * t * u * u) +
+            control1Y * (3 * t * t * u) + anchor1Y * (t * t * t)
         return result
     }
 
@@ -139,45 +139,45 @@
         val u = 1 - t
         val pointOnCurve = pointOnCurve(t)
         return Cubic(
-            anchorX0, anchorY0,
-            anchorX0 * u + controlX0 * t, anchorY0 * u + controlY0 * t,
-            anchorX0 * (u * u) + controlX0 * (2 * u * t) + controlX1 * (t * t),
-            anchorY0 * (u * u) + controlY0 * (2 * u * t) + controlY1 * (t * t),
+            anchor0X, anchor0Y,
+            anchor0X * u + control0X * t, anchor0Y * u + control0Y * t,
+            anchor0X * (u * u) + control0X * (2 * u * t) + control1X * (t * t),
+            anchor0Y * (u * u) + control0Y * (2 * u * t) + control1Y * (t * t),
             pointOnCurve.x, pointOnCurve.y
         ) to Cubic(
             // TODO: should calculate once and share the result
             pointOnCurve.x, pointOnCurve.y,
-            controlX0 * (u * u) + controlX1 * (2 * u * t) + anchorX1 * (t * t),
-            controlY0 * (u * u) + controlY1 * (2 * u * t) + anchorY1 * (t * t),
-            controlX1 * u + anchorX1 * t, controlY1 * u + anchorY1 * t,
-            anchorX1, anchorY1
+            control0X * (u * u) + control1X * (2 * u * t) + anchor1X * (t * t),
+            control0Y * (u * u) + control1Y * (2 * u * t) + anchor1Y * (t * t),
+            control1X * u + anchor1X * t, control1Y * u + anchor1Y * t,
+            anchor1X, anchor1Y
         )
     }
 
     /**
      * Utility function to reverse the control/anchor points for this curve.
      */
-    fun reverse() = Cubic(anchorX1, anchorY1, controlX1, controlY1, controlX0, controlY0,
-        anchorX0, anchorY0)
+    fun reverse() = Cubic(anchor1X, anchor1Y, control1X, control1Y, control0X, control0Y,
+        anchor0X, anchor0Y)
 
     /**
      * Operator overload to enable adding Cubic objects together, like "c0 + c1"
      */
     operator fun plus(o: Cubic) = Cubic(
-        anchorX0 + o.anchorX0, anchorY0 + o.anchorY0,
-        controlX0 + o.controlX0, controlY0 + o.controlY0,
-        controlX1 + o.controlX1, controlY1 + o.controlY1,
-        anchorX1 + o.anchorX1, anchorY1 + o.anchorY1
+        anchor0X + o.anchor0X, anchor0Y + o.anchor0Y,
+        control0X + o.control0X, control0Y + o.control0Y,
+        control1X + o.control1X, control1Y + o.control1Y,
+        anchor1X + o.anchor1X, anchor1Y + o.anchor1Y
     )
 
     /**
      * Operator overload to enable multiplying Cubics by a scalar value x, like "c0 * x"
      */
     operator fun times(x: Float) = Cubic(
-        anchorX0 * x, anchorY0 * x,
-        controlX0 * x, controlY0 * x,
-        controlX1 * x, controlY1 * x,
-        anchorX1 * x, anchorY1 * x
+        anchor0X * x, anchor0Y * x,
+        control0X * x, control0Y * x,
+        control1X * x, control1Y * x,
+        anchor1X * x, anchor1Y * x
     )
 
     /**
@@ -210,23 +210,23 @@
         if (points.size < 8) {
             throw IllegalArgumentException("points array must be of size >= 8")
         }
-        points[0] = anchorX0
-        points[1] = anchorY0
-        points[2] = controlX0
-        points[3] = controlY0
-        points[4] = controlX1
-        points[5] = controlY1
-        points[6] = anchorX1
-        points[7] = anchorY1
+        points[0] = anchor0X
+        points[1] = anchor0Y
+        points[2] = control0X
+        points[3] = control0Y
+        points[4] = control1X
+        points[5] = control1Y
+        points[6] = anchor1X
+        points[7] = anchor1Y
         matrix.mapPoints(points)
-        anchorX0 = points[0]
-        anchorY0 = points[1]
-        controlX0 = points[2]
-        controlY0 = points[3]
-        controlX1 = points[4]
-        controlY1 = points[5]
-        anchorX1 = points[6]
-        anchorY1 = points[7]
+        anchor0X = points[0]
+        anchor0Y = points[1]
+        control0X = points[2]
+        control0Y = points[3]
+        control1X = points[4]
+        control1Y = points[5]
+        anchor1X = points[6]
+        anchor1Y = points[7]
     }
 
     override fun equals(other: Any?): Boolean {
@@ -235,27 +235,27 @@
 
         other as Cubic
 
-        if (anchorX0 != other.anchorX0) return false
-        if (anchorY0 != other.anchorY0) return false
-        if (controlX0 != other.controlX0) return false
-        if (controlY0 != other.controlY0) return false
-        if (controlX1 != other.controlX1) return false
-        if (controlY1 != other.controlY1) return false
-        if (anchorX1 != other.anchorX1) return false
-        if (anchorY1 != other.anchorY1) return false
+        if (anchor0X != other.anchor0X) return false
+        if (anchor0Y != other.anchor0Y) return false
+        if (control0X != other.control0X) return false
+        if (control0Y != other.control0Y) return false
+        if (control1X != other.control1X) return false
+        if (control1Y != other.control1Y) return false
+        if (anchor1X != other.anchor1X) return false
+        if (anchor1Y != other.anchor1Y) return false
 
         return true
     }
 
     override fun hashCode(): Int {
-        var result = anchorX0.hashCode()
-        result = 31 * result + anchorY0.hashCode()
-        result = 31 * result + controlX0.hashCode()
-        result = 31 * result + controlY0.hashCode()
-        result = 31 * result + controlX1.hashCode()
-        result = 31 * result + controlY1.hashCode()
-        result = 31 * result + anchorX1.hashCode()
-        result = 31 * result + anchorY1.hashCode()
+        var result = anchor0X.hashCode()
+        result = 31 * result + anchor0Y.hashCode()
+        result = 31 * result + control0X.hashCode()
+        result = 31 * result + control0Y.hashCode()
+        result = 31 * result + control1X.hashCode()
+        result = 31 * result + control1Y.hashCode()
+        result = 31 * result + anchor1X.hashCode()
+        result = 31 * result + anchor1Y.hashCode()
         return result
     }
 
@@ -318,14 +318,14 @@
         @JvmStatic
         fun interpolate(start: Cubic, end: Cubic, t: Float): Cubic {
             return (Cubic(
-                interpolate(start.anchorX0, end.anchorX0, t),
-                interpolate(start.anchorY0, end.anchorY0, t),
-                interpolate(start.controlX0, end.controlX0, t),
-                interpolate(start.controlY0, end.controlY0, t),
-                interpolate(start.controlX1, end.controlX1, t),
-                interpolate(start.controlY1, end.controlY1, t),
-                interpolate(start.anchorX1, end.anchorX1, t),
-                interpolate(start.anchorY1, end.anchorY1, t),
+                interpolate(start.anchor0X, end.anchor0X, t),
+                interpolate(start.anchor0Y, end.anchor0Y, t),
+                interpolate(start.control0X, end.control0X, t),
+                interpolate(start.control0Y, end.control0Y, t),
+                interpolate(start.control1X, end.control1X, t),
+                interpolate(start.control1Y, end.control1Y, t),
+                interpolate(start.anchor1X, end.anchor1X, t),
+                interpolate(start.anchor1Y, end.anchor1Y, t),
             ))
         }
     }
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/CubicShape.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/CubicShape.kt
index 01f0233..84d5007 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/CubicShape.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/CubicShape.kt
@@ -43,7 +43,7 @@
         val copy = mutableListOf<Cubic>()
         var prevCubic = cubics[cubics.size - 1]
         for (cubic in cubics) {
-            if (cubic.anchorX0 != prevCubic.anchorX1 || cubic.anchorY0 != prevCubic.anchorY1) {
+            if (cubic.anchor0X != prevCubic.anchor1X || cubic.anchor0Y != prevCubic.anchor1Y) {
                 throw IllegalArgumentException("CubicShapes must be contiguous, with the anchor " +
                         "points of all curves matching the anchor points of the preceding and " +
                         "succeeding cubics")
@@ -132,12 +132,12 @@
     private fun updatePath() {
         path.rewind()
         if (cubics.isNotEmpty()) {
-            path.moveTo(cubics[0].anchorX0, cubics[0].anchorY0)
+            path.moveTo(cubics[0].anchor0X, cubics[0].anchor0Y)
             for (bezier in cubics) {
                 path.cubicTo(
-                    bezier.controlX0, bezier.controlY0,
-                    bezier.controlX1, bezier.controlY1,
-                    bezier.anchorX1, bezier.anchorY1
+                    bezier.control0X, bezier.control0Y,
+                    bezier.control1X, bezier.control1Y,
+                    bezier.anchor1X, bezier.anchor1Y
                 )
             }
         }
@@ -158,20 +158,20 @@
         var maxX = Float.MIN_VALUE
         var maxY = Float.MIN_VALUE
         for (bezier in cubics) {
-            if (bezier.anchorX0 < minX) minX = bezier.anchorX0
-            if (bezier.anchorY0 < minY) minY = bezier.anchorY0
-            if (bezier.anchorX0 > maxX) maxX = bezier.anchorX0
-            if (bezier.anchorY0 > maxY) maxY = bezier.anchorY0
+            if (bezier.anchor0X < minX) minX = bezier.anchor0X
+            if (bezier.anchor0Y < minY) minY = bezier.anchor0Y
+            if (bezier.anchor0X > maxX) maxX = bezier.anchor0X
+            if (bezier.anchor0Y > maxY) maxY = bezier.anchor0Y
 
-            if (bezier.controlX0 < minX) minX = bezier.controlX0
-            if (bezier.controlY0 < minY) minY = bezier.controlY0
-            if (bezier.controlX0 > maxX) maxX = bezier.controlX0
-            if (bezier.controlY0 > maxY) maxY = bezier.controlY0
+            if (bezier.control0X < minX) minX = bezier.control0X
+            if (bezier.control0Y < minY) minY = bezier.control0Y
+            if (bezier.control0X > maxX) maxX = bezier.control0X
+            if (bezier.control0Y > maxY) maxY = bezier.control0Y
 
-            if (bezier.controlX1 < minX) minX = bezier.controlX1
-            if (bezier.controlY1 < minY) minY = bezier.controlY1
-            if (bezier.controlX1 > maxX) maxX = bezier.controlX1
-            if (bezier.controlY1 > maxY) maxY = bezier.controlY1
+            if (bezier.control1X < minX) minX = bezier.control1X
+            if (bezier.control1Y < minY) minY = bezier.control1Y
+            if (bezier.control1X > maxX) maxX = bezier.control1X
+            if (bezier.control1Y > maxY) maxY = bezier.control1Y
             // No need to use x3/y3, since it is already taken into account in the next
             // curve's x0/y0 point.
         }
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/FeatureMapping.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/FeatureMapping.kt
index 8b2073a..a761ae3 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/FeatureMapping.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/FeatureMapping.kt
@@ -63,10 +63,10 @@
         debugLog(LOG_TAG) { "*** Feature distance ∞ for convex-vs-concave corners" }
         return Float.MAX_VALUE
     }
-    val c1x = (f1.cubics.first().anchorX0 + f1.cubics.last().anchorX1) / 2f
-    val c1y = (f1.cubics.first().anchorY0 + f1.cubics.last().anchorY1) / 2f
-    val c2x = (f2.cubics.first().anchorX0 + f2.cubics.last().anchorX1) / 2f
-    val c2y = (f2.cubics.first().anchorY0 + f2.cubics.last().anchorY1) / 2f
+    val c1x = (f1.cubics.first().anchor0X + f1.cubics.last().anchor1X) / 2f
+    val c1y = (f1.cubics.first().anchor0Y + f1.cubics.last().anchor1Y) / 2f
+    val c2x = (f2.cubics.first().anchor0X + f2.cubics.last().anchor1X) / 2f
+    val c2y = (f2.cubics.first().anchor0Y + f2.cubics.last().anchor1Y) / 2f
     val dx = c1x - c2x
     val dy = c1y - c2y
     return dx * dx + dy * dy
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Morph.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Morph.kt
index 641a16b..54452d9 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Morph.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/Morph.kt
@@ -78,35 +78,35 @@
         var maxX = Float.MIN_VALUE
         var maxY = Float.MIN_VALUE
         for (pair in morphMatch) {
-            if (pair.first.anchorX0 < minX) minX = pair.first.anchorX0
-            if (pair.first.anchorY0 < minY) minY = pair.first.anchorY0
-            if (pair.first.anchorX0 > maxX) maxX = pair.first.anchorX0
-            if (pair.first.anchorY0 > maxY) maxY = pair.first.anchorY0
+            if (pair.first.anchor0X < minX) minX = pair.first.anchor0X
+            if (pair.first.anchor0Y < minY) minY = pair.first.anchor0Y
+            if (pair.first.anchor0X > maxX) maxX = pair.first.anchor0X
+            if (pair.first.anchor0Y > maxY) maxY = pair.first.anchor0Y
 
-            if (pair.second.anchorX0 < minX) minX = pair.second.anchorX0
-            if (pair.second.anchorY0 < minY) minY = pair.second.anchorY0
-            if (pair.second.anchorX0 > maxX) maxX = pair.second.anchorX0
-            if (pair.second.anchorY0 > maxY) maxY = pair.second.anchorY0
+            if (pair.second.anchor0X < minX) minX = pair.second.anchor0X
+            if (pair.second.anchor0Y < minY) minY = pair.second.anchor0Y
+            if (pair.second.anchor0X > maxX) maxX = pair.second.anchor0X
+            if (pair.second.anchor0Y > maxY) maxY = pair.second.anchor0Y
 
-            if (pair.first.controlX0 < minX) minX = pair.first.controlX0
-            if (pair.first.controlY0 < minY) minY = pair.first.controlY0
-            if (pair.first.controlX0 > maxX) maxX = pair.first.controlX0
-            if (pair.first.controlY0 > maxY) maxY = pair.first.controlY0
+            if (pair.first.control0X < minX) minX = pair.first.control0X
+            if (pair.first.control0Y < minY) minY = pair.first.control0Y
+            if (pair.first.control0X > maxX) maxX = pair.first.control0X
+            if (pair.first.control0Y > maxY) maxY = pair.first.control0Y
 
-            if (pair.second.controlX0 < minX) minX = pair.second.controlX0
-            if (pair.second.controlY0 < minY) minY = pair.second.controlY0
-            if (pair.second.controlX0 > maxX) maxX = pair.second.controlX0
-            if (pair.second.controlY0 > maxY) maxY = pair.second.controlY0
+            if (pair.second.control0X < minX) minX = pair.second.control0X
+            if (pair.second.control0Y < minY) minY = pair.second.control0Y
+            if (pair.second.control0X > maxX) maxX = pair.second.control0X
+            if (pair.second.control0Y > maxY) maxY = pair.second.control0Y
 
-            if (pair.first.controlX1 < minX) minX = pair.first.controlX1
-            if (pair.first.controlY1 < minY) minY = pair.first.controlY1
-            if (pair.first.controlX1 > maxX) maxX = pair.first.controlX1
-            if (pair.first.controlY1 > maxY) maxY = pair.first.controlY1
+            if (pair.first.control1X < minX) minX = pair.first.control1X
+            if (pair.first.control1Y < minY) minY = pair.first.control1Y
+            if (pair.first.control1X > maxX) maxX = pair.first.control1X
+            if (pair.first.control1Y > maxY) maxY = pair.first.control1Y
 
-            if (pair.second.controlX1 < minX) minX = pair.second.controlX1
-            if (pair.second.controlY1 < minY) minY = pair.second.controlY1
-            if (pair.second.controlX1 > maxX) maxX = pair.second.controlX1
-            if (pair.second.controlY1 > maxY) maxY = pair.second.controlY1
+            if (pair.second.control1X < minX) minX = pair.second.control1X
+            if (pair.second.control1Y < minY) minY = pair.second.control1Y
+            if (pair.second.control1X > maxX) maxX = pair.second.control1X
+            if (pair.second.control1Y > maxY) maxY = pair.second.control1Y
             // Skip x3/y3 since every last point is the next curve's first point
         }
         bounds.set(minX, minY, maxX, maxY)
@@ -128,8 +128,8 @@
         // If the list is not empty, do an initial moveTo using the first element of the match.
         morphMatch.firstOrNull()?. let { first ->
             path.moveTo(
-                interpolate(first.first.anchorX0, first.second.anchorX0, progress),
-                interpolate(first.first.anchorY0, first.second.anchorY0, progress)
+                interpolate(first.first.anchor0X, first.second.anchor0X, progress),
+                interpolate(first.first.anchor0Y, first.second.anchor0Y, progress)
             )
         }
 
@@ -137,12 +137,12 @@
         for (i in 0..morphMatch.lastIndex) {
             val element = morphMatch[i]
             path.cubicTo(
-                interpolate(element.first.controlX0, element.second.controlX0, progress),
-                interpolate(element.first.controlY0, element.second.controlY0, progress),
-                interpolate(element.first.controlX1, element.second.controlX1, progress),
-                interpolate(element.first.controlY1, element.second.controlY1, progress),
-                interpolate(element.first.anchorX1, element.second.anchorX1, progress),
-                interpolate(element.first.anchorY1, element.second.anchorY1, progress),
+                interpolate(element.first.control0X, element.second.control0X, progress),
+                interpolate(element.first.control0Y, element.second.control0Y, progress),
+                interpolate(element.first.control1X, element.second.control1X, progress),
+                interpolate(element.first.control1Y, element.second.control1Y, progress),
+                interpolate(element.first.anchor1X, element.second.anchor1X, progress),
+                interpolate(element.first.anchor1Y, element.second.anchor1Y, progress),
             )
         }
         path.close()
@@ -333,19 +333,19 @@
             require(b1 == null && b2 == null)
 
             if (DEBUG) {
-                // Export as SVG path. Temporary while working with the Koru team.
+                // Export as SVG path
                 val showPoint: (PointF) -> String = {
                     "%.3f %.3f".format(it.x * 100, it.y * 100)
                 }
                 repeat(2) { listIx ->
                     val points = ret.map { if (listIx == 0) it.first else it.second }
                     debugLog(LOG_TAG) {
-                        "M " + showPoint(PointF(points.first().anchorX0,
-                            points.first().anchorY0)) + " " +
+                        "M " + showPoint(PointF(points.first().anchor0X,
+                            points.first().anchor0Y)) + " " +
                             points.joinToString(" ") {
-                                "C " + showPoint(PointF(it.controlX0, it.controlY0)) + ", " +
-                                    showPoint(PointF(it.controlX1, it.controlY1)) + ", " +
-                                    showPoint(PointF(it.anchorX1, it.anchorY1))
+                                "C " + showPoint(PointF(it.control0X, it.control0Y)) + ", " +
+                                    showPoint(PointF(it.control1X, it.control1Y)) + ", " +
+                                    showPoint(PointF(it.anchor1X, it.anchor1Y))
                             } + " Z"
                     }
                 }
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/PolygonMeasure.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/PolygonMeasure.kt
index 152ea9a..b060afb 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/PolygonMeasure.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/PolygonMeasure.kt
@@ -307,8 +307,8 @@
      */
     override fun measureCubic(c: Cubic) =
         positiveModulo(
-            angle(c.anchorX1 - centerX, c.anchorY1 - centerY) -
-                angle(c.anchorX0 - centerX, c.anchorY0 - centerY),
+            angle(c.anchor1X - centerX, c.anchor1Y - centerY) -
+                angle(c.anchor0X - centerX, c.anchor0Y - centerY),
             TwoPi
         ).let {
             // Avoid an empty cubic to measure almost TwoPi
@@ -316,7 +316,7 @@
         }
 
     override fun findCubicCutPoint(c: Cubic, m: Float): Float {
-        val angle0 = angle(c.anchorX0 - centerX, c.anchorY0 - centerY)
+        val angle0 = angle(c.anchor0X - centerX, c.anchor0Y - centerY)
         // TODO: use binary search.
         return findMinimum(0f, 1f, tolerance = 1e-5f) { t ->
             val curvePoint = c.pointOnCurve(t, tempPoint)
diff --git a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/RoundedPolygon.kt b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/RoundedPolygon.kt
index d0ba1e5..18af8d2 100644
--- a/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/RoundedPolygon.kt
+++ b/graphics/graphics-shapes/src/main/java/androidx/graphics/shapes/RoundedPolygon.kt
@@ -317,8 +317,8 @@
             tempFeatures.add(Corner(cornerIndices, currVertex, roundedCorners[i].center,
                 convex))
             tempFeatures.add(Edge(listOf(cubics.size)))
-            cubics.add(Cubic.straightLine(corners[i].last().anchorX1, corners[i].last().anchorY1,
-                corners[(i + 1) % n].first().anchorX0, corners[(i + 1) % n].first().anchorY0))
+            cubics.add(Cubic.straightLine(corners[i].last().anchor1X, corners[i].last().anchor1Y,
+                corners[(i + 1) % n].first().anchor0X, corners[(i + 1) % n].first().anchor0Y))
         }
         features = tempFeatures
         cubicShape.updateCubics(cubics)
@@ -558,8 +558,8 @@
         ).reverse()
         return listOf(
             flanking0,
-            Cubic.circularArc(center.x, center.y, flanking0.anchorX1, flanking0.anchorY1,
-                flanking2.anchorX0, flanking2.anchorY0),
+            Cubic.circularArc(center.x, center.y, flanking0.anchor1X, flanking0.anchor1Y,
+                flanking2.anchor0X, flanking2.anchor0Y),
             flanking2
         )
     }
diff --git a/graphics/integration-tests/testapp-compose/src/main/java/androidx/graphics/shapes/testcompose/DebugDraw.kt b/graphics/integration-tests/testapp-compose/src/main/java/androidx/graphics/shapes/testcompose/DebugDraw.kt
index 613c6a8..62c3fdc 100644
--- a/graphics/integration-tests/testapp-compose/src/main/java/androidx/graphics/shapes/testcompose/DebugDraw.kt
+++ b/graphics/integration-tests/testapp-compose/src/main/java/androidx/graphics/shapes/testcompose/DebugDraw.kt
@@ -38,19 +38,19 @@
 
     for (bezier in cubics) {
         // Draw red circles for start and end.
-        drawCircle(bezier.anchorX0, bezier.anchorY0, 6f, Color.Red, strokeWidth = 2f)
-        drawCircle(bezier.anchorX1, bezier.anchorY1, 8f, Color.Magenta, strokeWidth = 2f)
+        drawCircle(bezier.anchor0X, bezier.anchor0Y, 6f, Color.Red, strokeWidth = 2f)
+        drawCircle(bezier.anchor1X, bezier.anchor1Y, 8f, Color.Magenta, strokeWidth = 2f)
         // Draw a circle for the first control point, and a line from start to it.
         // The curve will start in this direction
 
-        drawLine(bezier.anchorX0, bezier.anchorY0, bezier.controlX0, bezier.controlY0, Color.Yellow,
+        drawLine(bezier.anchor0X, bezier.anchor0Y, bezier.control0X, bezier.control0Y, Color.Yellow,
             strokeWidth = 0f)
-        drawCircle(bezier.controlX0, bezier.controlY0, 4f, Color.Yellow, strokeWidth = 2f)
+        drawCircle(bezier.control0X, bezier.control0Y, 4f, Color.Yellow, strokeWidth = 2f)
         // Draw a circle for the second control point, and a line from it to the end.
         // The curve will end in this direction
-        drawLine(bezier.controlX1, bezier.controlY1, bezier.anchorX1, bezier.anchorY1, Color.Yellow,
+        drawLine(bezier.control1X, bezier.control1Y, bezier.anchor1X, bezier.anchor1Y, Color.Yellow,
             strokeWidth = 0f)
-        drawCircle(bezier.controlX1, bezier.controlY1, 4f, Color.Yellow, strokeWidth = 2f)
+        drawCircle(bezier.control1X, bezier.control1Y, 4f, Color.Yellow, strokeWidth = 2f)
     }
 }
 
diff --git a/health/health-services-client/api/current.txt b/health/health-services-client/api/current.txt
index aa45592..b807507 100644
--- a/health/health-services-client/api/current.txt
+++ b/health/health-services-client/api/current.txt
@@ -610,8 +610,7 @@
 
   public final class ExerciseTypeCapabilities {
     ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume);
-    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Set<? extends androidx.health.services.client.data.ExerciseEventType<?>> supportedExerciseEvents);
-    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Set<? extends androidx.health.services.client.data.ExerciseEventType<?>> supportedExerciseEvents, optional java.util.Map<androidx.health.services.client.data.ExerciseEventType<?>,? extends androidx.health.services.client.data.ExerciseEventCapabilities> exerciseEventCapabilities);
+    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Map<androidx.health.services.client.data.ExerciseEventType<?>,? extends androidx.health.services.client.data.ExerciseEventCapabilities> exerciseEventCapabilities);
     method public <C extends androidx.health.services.client.data.ExerciseEventCapabilities> C? getExerciseEventCapabilityDetails(androidx.health.services.client.data.ExerciseEventType<C> exerciseEventType);
     method public java.util.Set<androidx.health.services.client.data.DataType<?,?>> getSupportedDataTypes();
     method public java.util.Set<androidx.health.services.client.data.ExerciseEventType<?>> getSupportedExerciseEvents();
diff --git a/health/health-services-client/api/restricted_current.txt b/health/health-services-client/api/restricted_current.txt
index a74becf..53770c5 100644
--- a/health/health-services-client/api/restricted_current.txt
+++ b/health/health-services-client/api/restricted_current.txt
@@ -612,8 +612,7 @@
 
   public final class ExerciseTypeCapabilities {
     ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume);
-    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Set<? extends androidx.health.services.client.data.ExerciseEventType<?>> supportedExerciseEvents);
-    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Set<? extends androidx.health.services.client.data.ExerciseEventType<?>> supportedExerciseEvents, optional java.util.Map<androidx.health.services.client.data.ExerciseEventType<?>,? extends androidx.health.services.client.data.ExerciseEventCapabilities> exerciseEventCapabilities);
+    ctor public ExerciseTypeCapabilities(java.util.Set<? extends androidx.health.services.client.data.DataType<?,?>> supportedDataTypes, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedGoals, java.util.Map<androidx.health.services.client.data.AggregateDataType<?,?>,? extends java.util.Set<androidx.health.services.client.data.ComparisonType>> supportedMilestones, boolean supportsAutoPauseAndResume, optional java.util.Map<androidx.health.services.client.data.ExerciseEventType<?>,? extends androidx.health.services.client.data.ExerciseEventCapabilities> exerciseEventCapabilities);
     method public <C extends androidx.health.services.client.data.ExerciseEventCapabilities> C? getExerciseEventCapabilityDetails(androidx.health.services.client.data.ExerciseEventType<C> exerciseEventType);
     method public java.util.Set<androidx.health.services.client.data.DataType<?,?>> getSupportedDataTypes();
     method public java.util.Set<androidx.health.services.client.data.ExerciseEventType<?>> getSupportedExerciseEvents();
diff --git a/health/health-services-client/src/main/java/androidx/health/services/client/data/ExerciseTypeCapabilities.kt b/health/health-services-client/src/main/java/androidx/health/services/client/data/ExerciseTypeCapabilities.kt
index aa208f6..19ee1b4 100644
--- a/health/health-services-client/src/main/java/androidx/health/services/client/data/ExerciseTypeCapabilities.kt
+++ b/health/health-services-client/src/main/java/androidx/health/services/client/data/ExerciseTypeCapabilities.kt
@@ -33,8 +33,6 @@
     public val supportedMilestones: Map<AggregateDataType<*, *>, Set<ComparisonType>>,
     /** Returns `true` if the given exercise supports auto pause and resume. */
     public val supportsAutoPauseAndResume: Boolean,
-    /** Supported [ExerciseEvent]s for a given exercise. */
-    public val supportedExerciseEvents: Set<ExerciseEventType<*>> = emptySet(),
     /** Map from [ExerciseEventType]s to their [ExerciseEventCapabilities]. */
     internal val exerciseEventCapabilities: Map<ExerciseEventType<*>, ExerciseEventCapabilities> =
     emptyMap(),
@@ -69,11 +67,6 @@
             }
             .toMap(),
         supportsAutoPauseAndResume = proto.isAutoPauseAndResumeSupported,
-        supportedExerciseEvents =
-            proto.supportedExerciseEventsList
-                .filter { ExerciseEventCapabilities.fromProto(it) != null }
-                .map { ExerciseEventType.fromProto(it.exerciseEventType) }
-                .toSet(),
         exerciseEventCapabilities = proto.supportedExerciseEventsList
             .filter { ExerciseEventCapabilities.fromProto(it) != null }.associate { entry ->
                 ExerciseEventType.fromProto(entry.exerciseEventType) to
@@ -108,6 +101,10 @@
             .addAllSupportedExerciseEvents(exerciseEventCapabilities.map { it.value.toProto() })
             .build()
 
+    /** Returns the set of supported [ExerciseEventType]s on this device. */
+    public val supportedExerciseEvents: Set<ExerciseEventType<*>>
+        get() = this.exerciseEventCapabilities.keys
+
     /** Returns the [ExerciseEventCapabilities] for a requested [ExerciseEventType]. */
     public fun <C : ExerciseEventCapabilities> getExerciseEventCapabilityDetails(
         exerciseEventType: ExerciseEventType<C>
diff --git a/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseCapabilitiesTest.kt b/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseCapabilitiesTest.kt
index fc24a36..5e6b537 100644
--- a/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseCapabilitiesTest.kt
+++ b/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseCapabilitiesTest.kt
@@ -62,6 +62,10 @@
         assertThat(
             EXERCISE_CAPABILITIES.getExerciseTypeCapabilities(
                 ExerciseType.GOLF).supportedExerciseEvents
+        ).isEqualTo(setOf(ExerciseEventType.GOLF_SHOT_EVENT))
+        assertThat(
+            EXERCISE_CAPABILITIES.getExerciseTypeCapabilities(
+                ExerciseType.GOLF).supportedExerciseEvents
         ).isEqualTo(
             EXERCISE_CAPABILITIES.typeToCapabilities.get(ExerciseType.GOLF)?.supportedExerciseEvents
         )
@@ -172,7 +176,6 @@
             supportedGoals = emptyMap(),
             supportedMilestones = emptyMap(),
             supportsAutoPauseAndResume = true,
-            supportedExerciseEvents = setOf(ExerciseEventType.GOLF_SHOT_EVENT),
             exerciseEventCapabilities =
             ImmutableMap.of(ExerciseEventType.GOLF_SHOT_EVENT, GOLF_SHOT_EVENT_CAPABILITIES),
         )
diff --git a/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseTypeCapabilitiesTest.kt b/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseTypeCapabilitiesTest.kt
index 80f12ef..9ef8525 100644
--- a/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseTypeCapabilitiesTest.kt
+++ b/health/health-services-client/src/test/java/androidx/health/services/client/data/ExerciseTypeCapabilitiesTest.kt
@@ -37,7 +37,6 @@
             supportedGoals = emptyMap(),
             supportedMilestones = emptyMap(),
             supportsAutoPauseAndResume = true,
-            supportedExerciseEvents = setOf(ExerciseEventType.UNKNOWN),
             exerciseEventCapabilities =
             ImmutableMap.of(ExerciseEventType.UNKNOWN,
                TestEventCapabilities(false)
diff --git a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
index 093ecd9..ca64ac2 100644
--- a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
+++ b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
@@ -49,11 +49,15 @@
     // Low value will use maximum prediction, high value will use no prediction.
     private static final float LOW_JANK = 0.02f;
     private static final float HIGH_JANK = 0.2f;
+    private static final float ACCURATE_LOW_JANK = 0.2f;
+    private static final float ACCURATE_HIGH_JANK = 1f;
 
     // Range of pen speed to expect (in dp / ms).
     // Low value will not use prediction, high value will use full prediction.
     private static final float LOW_SPEED = 0.0f;
     private static final float HIGH_SPEED = 2.0f;
+    private static final float ACCURATE_LOW_SPEED = 0.0f;
+    private static final float ACCURATE_HIGH_SPEED = 0.0f;
 
     private static final int EVENT_TIME_IGNORED_THRESHOLD_MS = 20;
 
@@ -210,9 +214,21 @@
         // Adjust prediction distance based on confidence of mKalman filter as well as movement
         // speed.
         double speedAbs = mVelocity.magnitude() / mReportRateMs;
-        double speedFactor = normalizeRange(speedAbs, LOW_SPEED, HIGH_SPEED);
+        float lowSpeed, highSpeed, lowJank, highJank;
+        if (usingAccurateTool()) {
+            lowSpeed = ACCURATE_LOW_SPEED;
+            highSpeed = ACCURATE_HIGH_SPEED;
+            lowJank = ACCURATE_LOW_JANK;
+            highJank = ACCURATE_HIGH_JANK;
+        } else {
+            lowSpeed = LOW_SPEED;
+            highSpeed = HIGH_SPEED;
+            lowJank = LOW_JANK;
+            highJank = HIGH_JANK;
+        }
+        double speedFactor = normalizeRange(speedAbs, lowSpeed, highSpeed);
         double jankAbs = mJank.magnitude();
-        double jankFactor = 1.0 - normalizeRange(jankAbs, LOW_JANK, HIGH_JANK);
+        double jankFactor = 1.0 - normalizeRange(jankAbs, lowJank, highJank);
         double confidenceFactor = speedFactor * jankFactor;
 
         MotionEvent predictedEvent = null;
@@ -282,6 +298,10 @@
         return predictedEvent;
     }
 
+    private boolean usingAccurateTool() {
+        return (mToolType != MotionEvent.TOOL_TYPE_FINGER);
+    }
+
     private double normalizeRange(double x, double min, double max) {
         double normalized = (x - min) / (max - min);
         return Math.min(1.0, Math.max(normalized, 0.0));
diff --git a/libraryversions.toml b/libraryversions.toml
index f57efb3..bcf50a1 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -18,7 +18,7 @@
 CAMERA_PIPE = "1.0.0-alpha01"
 CARDVIEW = "1.1.0-alpha01"
 CAR_APP = "1.4.0-beta01"
-COLLECTION = "1.3.0-beta01"
+COLLECTION = "1.4.0-alpha01"
 COMPOSE = "1.6.0-alpha03"
 COMPOSE_COMPILER = "1.5.1"
 COMPOSE_MATERIAL3 = "1.2.0-alpha05"
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.java
deleted file mode 100644
index 4f5b51a..0000000
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle.observers;
-
-import androidx.annotation.NonNull;
-import androidx.lifecycle.GeneratedAdapter;
-import androidx.lifecycle.Lifecycle;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.MethodCallsLogger;
-
-public class Interface1_LifecycleAdapter implements GeneratedAdapter {
-
-    public Interface1_LifecycleAdapter(Interface1 base) {
-    }
-
-    @Override
-    public void callMethods(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event,
-            boolean onAny, MethodCallsLogger logger) {
-
-    }
-}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.kt b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.kt
new file mode 100644
index 0000000..38ab559
--- /dev/null
+++ b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface1_LifecycleAdapter.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.lifecycle.observers
+
+import androidx.lifecycle.GeneratedAdapter
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MethodCallsLogger
+
+@Suppress("UNUSED_PARAMETER")
+class Interface1_LifecycleAdapter(base: Interface1) : GeneratedAdapter {
+    override fun callMethods(
+        source: LifecycleOwner,
+        event: Lifecycle.Event,
+        onAny: Boolean,
+        logger: MethodCallsLogger?
+    ) {}
+}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.java
deleted file mode 100644
index 381d104..0000000
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle.observers;
-
-import androidx.annotation.NonNull;
-import androidx.lifecycle.GeneratedAdapter;
-import androidx.lifecycle.Lifecycle;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.MethodCallsLogger;
-
-public class Interface2_LifecycleAdapter implements GeneratedAdapter {
-
-    public Interface2_LifecycleAdapter(Interface2 base) {
-    }
-
-    @Override
-    public void callMethods(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event,
-            boolean onAny, MethodCallsLogger logger) {
-
-    }
-}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.kt b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.kt
new file mode 100644
index 0000000..83129a8
--- /dev/null
+++ b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/Interface2_LifecycleAdapter.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.lifecycle.observers
+
+import androidx.lifecycle.GeneratedAdapter
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MethodCallsLogger
+
+@Suppress("UNUSED_PARAMETER")
+class Interface2_LifecycleAdapter(base: Interface2) : GeneratedAdapter {
+    override fun callMethods(
+        source: LifecycleOwner,
+        event: Lifecycle.Event,
+        onAny: Boolean,
+        logger: MethodCallsLogger?
+    ) {}
+}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.kt
similarity index 81%
rename from lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
rename to lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.kt
index 0b07605..77fe0b7 100644
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
+++ b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.kt
@@ -13,11 +13,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package androidx.lifecycle.observers
 
-package androidx.lifecycle.observers;
-
-public class InterfaceImpl1 implements Interface1 {
-    @Override
-    public void onCreate() {
-    }
+class InterfaceImpl1 : Interface1 {
+    override fun onCreate() {}
 }
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.kt
similarity index 80%
copy from lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
copy to lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.kt
index 0b07605..03b6d86 100644
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
+++ b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.kt
@@ -13,11 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package androidx.lifecycle.observers
 
-package androidx.lifecycle.observers;
-
-public class InterfaceImpl1 implements Interface1 {
-    @Override
-    public void onCreate() {
-    }
+class InterfaceImpl2 : Interface1, Interface2 {
+    override fun onCreate() {}
+    override fun onDestroy() {}
 }
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.java
deleted file mode 100644
index e6d8a63..0000000
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle.observers;
-
-public class InterfaceImpl3 extends Base implements Interface1 {
-    @Override
-    public void onCreate() {
-    }
-}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.kt
similarity index 81%
copy from lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
copy to lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.kt
index 0b07605..17344da 100644
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl1.java
+++ b/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl3.kt
@@ -13,11 +13,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package androidx.lifecycle.observers
 
-package androidx.lifecycle.observers;
-
-public class InterfaceImpl1 implements Interface1 {
-    @Override
-    public void onCreate() {
-    }
+class InterfaceImpl3 : Base(), Interface1 {
+    override fun onCreate() {}
 }
diff --git a/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.java b/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.java
deleted file mode 100644
index 0956a80..0000000
--- a/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT 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 test.library;
-
-import androidx.lifecycle.GeneratedAdapter;
-import androidx.lifecycle.Lifecycle;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.MethodCallsLogger;
-import java.lang.Override;
-import javax.annotation.Generated;
-
-@Generated("androidx.lifecycle.LifecycleProcessor")
-public class ObserverNoAdapter_LifecycleAdapter implements GeneratedAdapter {
-    final ObserverNoAdapter mReceiver;
-
-    ObserverNoAdapter_LifecycleAdapter(ObserverNoAdapter receiver) {
-        this.mReceiver = receiver;
-    }
-
-    @Override
-    public void callMethods(LifecycleOwner owner, Lifecycle.Event event, boolean onAny,
-            MethodCallsLogger logger) {
-        boolean hasLogger = logger != null;
-        if (onAny) {
-            return;
-        }
-        if (event == Lifecycle.Event.ON_STOP) {
-            if (!hasLogger || logger.approveCall("doOnStop", 1)) {
-                mReceiver.doOnStop();
-            }
-            return;
-        }
-    }
-}
diff --git a/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.kt b/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.kt
new file mode 100644
index 0000000..bf9040f
--- /dev/null
+++ b/lifecycle/lifecycle-compiler/src/test/test-data/expected/ObserverNoAdapter_LifecycleAdapter.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 test.library
+
+import androidx.lifecycle.GeneratedAdapter
+import androidx.lifecycle.Lifecycle.Event
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MethodCallsLogger
+import javax.annotation.Generated
+
+@Generated(value = ["androidx.lifecycle.LifecycleProcessor"])
+class ObserverNoAdapter_LifecycleAdapter internal constructor(receiver: ObserverNoAdapter) :
+    GeneratedAdapter {
+    val receiver: ObserverNoAdapter = receiver
+
+    override fun callMethods(
+        owner: LifecycleOwner,
+        event: Event,
+        onAny: Boolean,
+        logger: MethodCallsLogger
+    ) {
+        val hasLogger = logger != null
+        if (onAny) {
+            return
+        }
+        if (event === Event.ON_STOP) {
+            if (!hasLogger || logger.approveCall("doOnStop", 1)) {
+                receiver.doOnStop()
+            }
+            return
+        }
+    }
+}
diff --git a/lifecycle/lifecycle-livedata-core-ktx/src/test/java/androidx/lifecycle/LiveDataTest.kt b/lifecycle/lifecycle-livedata-core-ktx/src/test/java/androidx/lifecycle/LiveDataTest.kt
deleted file mode 100644
index cb4fe29..0000000
--- a/lifecycle/lifecycle-livedata-core-ktx/src/test/java/androidx/lifecycle/LiveDataTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle
-
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import androidx.lifecycle.testing.TestLifecycleOwner
-import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
-import org.junit.Rule
-import org.junit.Test
-
-@Suppress("DEPRECATION")
-class LiveDataTest {
-
-    @get:Rule
-    val mInstantTaskExecutorRule = InstantTaskExecutorRule()
-
-    @Test
-    fun observe() {
-        @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
-        val lifecycleOwner = TestLifecycleOwner(coroutineDispatcher = UnconfinedTestDispatcher())
-
-        val liveData = MutableLiveData<String>()
-        var value = ""
-        liveData.observe<String>(lifecycleOwner) { newValue ->
-            value = newValue
-        }
-
-        liveData.value = "261"
-        assertThat(value).isEqualTo("261")
-    }
-}
diff --git a/lifecycle/lifecycle-livedata-core/src/test/java/androidx/lifecycle/LiveDataTest.kt b/lifecycle/lifecycle-livedata-core/src/test/java/androidx/lifecycle/LiveDataTest.kt
index b280b60..2a61a84 100644
--- a/lifecycle/lifecycle-livedata-core/src/test/java/androidx/lifecycle/LiveDataTest.kt
+++ b/lifecycle/lifecycle-livedata-core/src/test/java/androidx/lifecycle/LiveDataTest.kt
@@ -100,6 +100,21 @@
     }
 
     @Test
+    fun observe() {
+        @OptIn(ExperimentalCoroutinesApi::class)
+        val lifecycleOwner = TestLifecycleOwner(coroutineDispatcher = UnconfinedTestDispatcher())
+
+        val liveData = MutableLiveData<String>()
+        var value = ""
+        liveData.observe(lifecycleOwner) { newValue ->
+            value = newValue
+        }
+
+        liveData.value = "261"
+        assertThat(value, `is`("261"))
+    }
+
+    @Test
     fun testIsInitialized() {
         assertThat(liveData.isInitialized, `is`(false))
         assertThat(liveData.value, `is`(nullValue()))
diff --git a/lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.java b/lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.java
deleted file mode 100644
index 60d703f..0000000
--- a/lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle.util;
-
-import androidx.arch.core.executor.TaskExecutor;
-
-public class InstantTaskExecutor extends TaskExecutor {
-    @Override
-    public void executeOnDiskIO(Runnable runnable) {
-        runnable.run();
-    }
-
-    @Override
-    public void postToMainThread(Runnable runnable) {
-        runnable.run();
-    }
-
-    @Override
-    public boolean isMainThread() {
-        return true;
-    }
-}
diff --git a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java b/lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.kt
similarity index 63%
copy from lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java
copy to lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.kt
index 2f5d9d9..4d2db65 100644
--- a/lifecycle/lifecycle-common/src/test/java/androidx/lifecycle/observers/InterfaceImpl2.java
+++ b/lifecycle/lifecycle-livedata/src/test/java/androidx/lifecycle/util/InstantTaskExecutor.kt
@@ -13,16 +13,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package androidx.lifecycle.util
 
-package androidx.lifecycle.observers;
+import androidx.arch.core.executor.TaskExecutor
 
-public class InterfaceImpl2 implements Interface1, Interface2 {
-    @Override
-    public void onCreate() {
+open class InstantTaskExecutor : TaskExecutor() {
+
+    override fun executeOnDiskIO(runnable: Runnable) {
+        runnable.run()
     }
 
-    @Override
-    public void onDestroy() {
+    override fun postToMainThread(runnable: Runnable) {
+        runnable.run()
+    }
 
+    override fun isMainThread(): Boolean {
+        return true
     }
 }
diff --git a/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/ServiceLifecycleTest.kt b/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/ServiceLifecycleTest.kt
index 439fdc0..641f1e1 100644
--- a/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/ServiceLifecycleTest.kt
+++ b/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/ServiceLifecycleTest.kt
@@ -29,8 +29,8 @@
 import androidx.lifecycle.Lifecycle.Event.ON_START
 import androidx.lifecycle.Lifecycle.Event.ON_STOP
 import androidx.lifecycle.service.TestService
-import androidx.lifecycle.service.TestService.ACTION_LOG_EVENT
-import androidx.lifecycle.service.TestService.EXTRA_KEY_EVENT
+import androidx.lifecycle.service.TestService.Companion.ACTION_LOG_EVENT
+import androidx.lifecycle.service.TestService.Companion.EXTRA_KEY_EVENT
 import androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance
 import androidx.test.core.app.ApplicationProvider.getApplicationContext
 import androidx.test.ext.junit.runners.AndroidJUnit4
diff --git a/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.java b/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.java
deleted file mode 100644
index d82a895..0000000
--- a/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.lifecycle.service;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Binder;
-import android.os.IBinder;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.lifecycle.LifecycleEventObserver;
-import androidx.lifecycle.LifecycleService;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-
-public class TestService extends LifecycleService {
-
-    public static final String ACTION_LOG_EVENT = "ACTION_LOG_EVENT";
-    public static final String EXTRA_KEY_EVENT = "EXTRA_KEY_EVENT";
-
-    private final IBinder mBinder = new Binder();
-
-    public TestService() {
-        getLifecycle().addObserver((LifecycleEventObserver) (source, event) -> {
-            Context context = (TestService) source;
-            Intent intent = new Intent(ACTION_LOG_EVENT);
-            intent.putExtra(EXTRA_KEY_EVENT, event);
-            LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
-        });
-    }
-
-    @Nullable
-    @Override
-    public IBinder onBind(@NonNull Intent intent) {
-        super.onBind(intent);
-        return mBinder;
-    }
-}
diff --git a/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.kt b/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.kt
new file mode 100644
index 0000000..1a9bd23
--- /dev/null
+++ b/lifecycle/lifecycle-service/src/androidTest/java/androidx/lifecycle/service/TestService.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.lifecycle.service
+
+import android.content.Context
+import android.content.Intent
+import android.os.Binder
+import android.os.IBinder
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleService
+import androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance
+
+class TestService : LifecycleService() {
+    private val binder = Binder()
+
+    init {
+        lifecycle.addObserver(
+            LifecycleEventObserver { source, event ->
+                val context: Context = source as TestService
+                val intent = Intent(ACTION_LOG_EVENT)
+                intent.putExtra(EXTRA_KEY_EVENT, event)
+                getInstance(context).sendBroadcast(intent)
+            }
+        )
+    }
+
+    override fun onBind(intent: Intent): IBinder {
+        super.onBind(intent)
+        return binder
+    }
+
+    companion object {
+        const val ACTION_LOG_EVENT = "ACTION_LOG_EVENT"
+        const val EXTRA_KEY_EVENT = "EXTRA_KEY_EVENT"
+    }
+}
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
index 36251c0..e182fd9 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouter.java
@@ -82,7 +82,8 @@
  */
 // TODO: Add the javadoc for manifest requirements about 'Package visibility' in Android 11
 public final class MediaRouter {
-    static final String TAG = "MediaRouter";
+    // The "Ax" prefix disambiguates from the platform's MediaRouter.
+    static final String TAG = "AxMediaRouter";
     static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
     @IntDef({
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybean.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi16Impl.java
similarity index 80%
rename from mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybean.java
rename to mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi16Impl.java
index 0494663..b3b9037 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybean.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi16Impl.java
@@ -22,6 +22,7 @@
 import android.os.Build;
 import android.util.Log;
 
+import androidx.annotation.DoNotInline;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
@@ -31,8 +32,14 @@
 import java.util.ArrayList;
 import java.util.List;
 
+/**
+ * Provides methods for {@link MediaRouter} for API 16 and above. This class is used for API
+ * Compatibility.
+ *
+ * @see <a href="http://go/androidx/api_guidelines/compat.md">Implementing compatibility</a>
+ */
 @RequiresApi(16)
-final class MediaRouterJellybean {
+/* package */ final class MediaRouterApi16Impl {
     private static final String TAG = "MediaRouterJellybean";
 
     public static final int ROUTE_TYPE_LIVE_AUDIO = 0x1;
@@ -40,14 +47,18 @@
     public static final int ROUTE_TYPE_USER = 0x00800000;
 
     public static final int ALL_ROUTE_TYPES =
-            MediaRouterJellybean.ROUTE_TYPE_LIVE_AUDIO
-                    | MediaRouterJellybean.ROUTE_TYPE_LIVE_VIDEO
-                    | MediaRouterJellybean.ROUTE_TYPE_USER;
+            MediaRouterApi16Impl.ROUTE_TYPE_LIVE_AUDIO
+                    | MediaRouterApi16Impl.ROUTE_TYPE_LIVE_VIDEO
+                    | MediaRouterApi16Impl.ROUTE_TYPE_USER;
 
+    private MediaRouterApi16Impl() {}
+
+    @DoNotInline
     public static android.media.MediaRouter getMediaRouter(Context context) {
         return (android.media.MediaRouter) context.getSystemService(Context.MEDIA_ROUTER_SERVICE);
     }
 
+    @DoNotInline
     public static List<MediaRouter.RouteInfo> getRoutes(android.media.MediaRouter router) {
         final int count = router.getRouteCount();
         List<MediaRouter.RouteInfo> out = new ArrayList<>(count);
@@ -57,44 +68,55 @@
         return out;
     }
 
-    public static MediaRouter.RouteInfo getSelectedRoute(android.media.MediaRouter router,
-            int type) {
+    @DoNotInline
+    public static MediaRouter.RouteInfo getSelectedRoute(
+            android.media.MediaRouter router, int type) {
         return router.getSelectedRoute(type);
     }
 
-    public static void selectRoute(android.media.MediaRouter router, int types,
+    @DoNotInline
+    public static void selectRoute(
+            android.media.MediaRouter router,
+            int types,
             android.media.MediaRouter.RouteInfo route) {
         router.selectRoute(types, route);
     }
 
-    public static void addCallback(android.media.MediaRouter router, int types,
+    @DoNotInline
+    public static void addCallback(
+            android.media.MediaRouter router,
+            int types,
             android.media.MediaRouter.Callback callback) {
         router.addCallback(types, callback);
     }
 
-    public static void removeCallback(android.media.MediaRouter router,
-            android.media.MediaRouter.Callback callback) {
+    @DoNotInline
+    public static void removeCallback(
+            android.media.MediaRouter router, android.media.MediaRouter.Callback callback) {
         router.removeCallback(callback);
     }
 
+    @DoNotInline
     public static android.media.MediaRouter.RouteCategory createRouteCategory(
-            android.media.MediaRouter router,
-            String name, boolean isGroupable) {
+            android.media.MediaRouter router, String name, boolean isGroupable) {
         return router.createRouteCategory(name, isGroupable);
     }
 
-    public static MediaRouter.UserRouteInfo createUserRoute(android.media.MediaRouter router,
-            android.media.MediaRouter.RouteCategory category) {
+    @DoNotInline
+    public static MediaRouter.UserRouteInfo createUserRoute(
+            android.media.MediaRouter router, android.media.MediaRouter.RouteCategory category) {
         return router.createUserRoute(category);
     }
 
-    public static void addUserRoute(android.media.MediaRouter router,
-            android.media.MediaRouter.UserRouteInfo route) {
+    @DoNotInline
+    public static void addUserRoute(
+            android.media.MediaRouter router, android.media.MediaRouter.UserRouteInfo route) {
         router.addUserRoute(route);
     }
 
-    public static void removeUserRoute(android.media.MediaRouter router,
-            android.media.MediaRouter.UserRouteInfo route) {
+    @DoNotInline
+    public static void removeUserRoute(
+            android.media.MediaRouter router, android.media.MediaRouter.UserRouteInfo route) {
         try {
             router.removeUserRoute(route);
         } catch (IllegalArgumentException e) {
@@ -103,114 +125,139 @@
         }
     }
 
-    public static CallbackProxy<Callback> createCallback(Callback callback) {
+    @DoNotInline
+    public static android.media.MediaRouter.Callback createCallback(Callback callback) {
         return new CallbackProxy<>(callback);
     }
 
-    public static VolumeCallbackProxy<VolumeCallback> createVolumeCallback(
+    @DoNotInline
+    public static android.media.MediaRouter.VolumeCallback createVolumeCallback(
             VolumeCallback callback) {
         return new VolumeCallbackProxy<>(callback);
     }
 
+    @RequiresApi(16)
     public static final class RouteInfo {
+
+        private RouteInfo() {}
+
+        @DoNotInline
         @NonNull
-        public static CharSequence getName(@NonNull android.media.MediaRouter.RouteInfo route,
-                @NonNull Context context) {
+        public static CharSequence getName(
+                @NonNull android.media.MediaRouter.RouteInfo route, @NonNull Context context) {
             return route.getName(context);
         }
 
+        @DoNotInline
         public static int getSupportedTypes(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getSupportedTypes();
         }
 
+        @DoNotInline
         public static int getPlaybackType(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getPlaybackType();
         }
 
+        @DoNotInline
         public static int getPlaybackStream(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getPlaybackStream();
         }
 
+        @DoNotInline
         public static int getVolume(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getVolume();
         }
 
+        @DoNotInline
         public static int getVolumeMax(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getVolumeMax();
         }
 
+        @DoNotInline
         public static int getVolumeHandling(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getVolumeHandling();
         }
 
+        @DoNotInline
         @Nullable
         public static Object getTag(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.getTag();
         }
 
-        public static void setTag(@NonNull android.media.MediaRouter.RouteInfo route,
-                @Nullable Object tag) {
+        @DoNotInline
+        public static void setTag(
+                @NonNull android.media.MediaRouter.RouteInfo route, @Nullable Object tag) {
             route.setTag(tag);
         }
 
-        public static void requestSetVolume(@NonNull android.media.MediaRouter.RouteInfo route,
-                int volume) {
+        @DoNotInline
+        public static void requestSetVolume(
+                @NonNull android.media.MediaRouter.RouteInfo route, int volume) {
             route.requestSetVolume(volume);
         }
 
+        @DoNotInline
         public static void requestUpdateVolume(
                 @NonNull android.media.MediaRouter.RouteInfo route, int direction) {
             route.requestUpdateVolume(direction);
         }
-
-        private RouteInfo() {
-        }
     }
 
+    @RequiresApi(16)
     public static final class UserRouteInfo {
-        public static void setName(@NonNull android.media.MediaRouter.UserRouteInfo route,
+
+        private UserRouteInfo() {}
+
+        @DoNotInline
+        public static void setName(
+                @NonNull android.media.MediaRouter.UserRouteInfo route,
                 @NonNull CharSequence name) {
             route.setName(name);
         }
 
+        @DoNotInline
         public static void setPlaybackType(
                 @NonNull android.media.MediaRouter.UserRouteInfo route, int type) {
             route.setPlaybackType(type);
         }
 
+        @DoNotInline
         public static void setPlaybackStream(
                 @NonNull android.media.MediaRouter.UserRouteInfo route, int stream) {
             route.setPlaybackStream(stream);
         }
 
-        public static void setVolume(@NonNull android.media.MediaRouter.UserRouteInfo route,
-                int volume) {
+        @DoNotInline
+        public static void setVolume(
+                @NonNull android.media.MediaRouter.UserRouteInfo route, int volume) {
             route.setVolume(volume);
         }
 
-        public static void setVolumeMax(@NonNull android.media.MediaRouter.UserRouteInfo route,
-                int volumeMax) {
+        @DoNotInline
+        public static void setVolumeMax(
+                @NonNull android.media.MediaRouter.UserRouteInfo route, int volumeMax) {
             route.setVolumeMax(volumeMax);
         }
 
+        @DoNotInline
         public static void setVolumeHandling(
                 @NonNull android.media.MediaRouter.UserRouteInfo route, int volumeHandling) {
             route.setVolumeHandling(volumeHandling);
         }
 
-        public static void setVolumeCallback(@NonNull android.media.MediaRouter.UserRouteInfo route,
+        @DoNotInline
+        public static void setVolumeCallback(
+                @NonNull android.media.MediaRouter.UserRouteInfo route,
                 @NonNull android.media.MediaRouter.VolumeCallback volumeCallback) {
             route.setVolumeCallback(volumeCallback);
         }
 
+        @DoNotInline
         public static void setRemoteControlClient(
                 @NonNull android.media.MediaRouter.UserRouteInfo route,
                 @Nullable android.media.RemoteControlClient rcc) {
             route.setRemoteControlClient(rcc);
         }
-
-        private UserRouteInfo() {
-        }
     }
 
     public interface Callback {
@@ -410,7 +457,4 @@
             mCallback.onVolumeUpdateRequest(route, direction);
         }
     }
-
-    private MediaRouterJellybean() {
-    }
 }
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybeanMr1.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi17Impl.java
similarity index 90%
rename from mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybeanMr1.java
rename to mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi17Impl.java
index 0d6642f..bf90bf8 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterJellybeanMr1.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouterApi17Impl.java
@@ -19,11 +19,13 @@
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.hardware.display.DisplayManager;
+import android.media.MediaRouter;
 import android.os.Build;
 import android.os.Handler;
 import android.util.Log;
 import android.view.Display;
 
+import androidx.annotation.DoNotInline;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
@@ -32,19 +34,32 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 
+/**
+ * Provides methods for {@link MediaRouter} for API 17 and above. This class is used for API
+ * Compatibility.
+ *
+ * @see <a href="http://go/androidx/api_guidelines/compat.md">Implementing compatibility</a>
+ */
 @RequiresApi(17)
-final class MediaRouterJellybeanMr1 {
+/* package */ final class MediaRouterApi17Impl {
     private static final String TAG = "MediaRouterJellybeanMr1";
 
-    public static CallbackProxy<Callback> createCallback(Callback callback) {
+    private MediaRouterApi17Impl() {}
+
+    public static android.media.MediaRouter.Callback createCallback(Callback callback) {
         return new CallbackProxy<>(callback);
     }
 
     public static final class RouteInfo {
+
+        private RouteInfo() {}
+
+        @DoNotInline
         public static boolean isEnabled(@NonNull android.media.MediaRouter.RouteInfo route) {
             return route.isEnabled();
         }
 
+        @DoNotInline
         @Nullable
         public static Display getPresentationDisplay(
                 @NonNull android.media.MediaRouter.RouteInfo route) {
@@ -57,12 +72,9 @@
             }
             return null;
         }
-
-        private RouteInfo() {
-        }
     }
 
-    public interface Callback extends MediaRouterJellybean.Callback {
+    public interface Callback extends MediaRouterApi16Impl.Callback {
         void onRoutePresentationDisplayChanged(@NonNull android.media.MediaRouter.RouteInfo route);
     }
 
@@ -109,7 +121,7 @@
             // See also the JellybeanMr2Impl implementation of this method.
             // This was fixed in JB MR2 by adding a new overload of addCallback() to
             // enable active scanning on request.
-            if ((routeTypes & MediaRouterJellybean.ROUTE_TYPE_LIVE_VIDEO) != 0) {
+            if ((routeTypes & MediaRouterApi16Impl.ROUTE_TYPE_LIVE_VIDEO) != 0) {
                 if (!mActivelyScanningWifiDisplays) {
                     if (mScanWifiDisplaysMethod != null) {
                         mActivelyScanningWifiDisplays = true;
@@ -191,8 +203,7 @@
         }
     }
 
-    static class CallbackProxy<T extends Callback>
-            extends MediaRouterJellybean.CallbackProxy<T> {
+    static class CallbackProxy<T extends Callback> extends MediaRouterApi16Impl.CallbackProxy<T> {
         CallbackProxy(T callback) {
             super(callback);
         }
@@ -203,7 +214,4 @@
             mCallback.onRoutePresentationDisplayChanged(route);
         }
     }
-
-    private MediaRouterJellybeanMr1() {
-    }
 }
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RemoteControlClientCompat.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RemoteControlClientCompat.java
index 4033297..993e9a5 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RemoteControlClientCompat.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/RemoteControlClientCompat.java
@@ -135,37 +135,30 @@
         JellybeanImpl(Context context, android.media.RemoteControlClient rcc) {
             super(context, rcc);
 
-            mRouter = MediaRouterJellybean.getMediaRouter(context);
-            mUserRouteCategory = MediaRouterJellybean.createRouteCategory(
-                    mRouter, "", false);
-            mUserRoute = MediaRouterJellybean.createUserRoute(
-                    mRouter, mUserRouteCategory);
+            mRouter = MediaRouterApi16Impl.getMediaRouter(context);
+            mUserRouteCategory = MediaRouterApi16Impl.createRouteCategory(mRouter, "", false);
+            mUserRoute = MediaRouterApi16Impl.createUserRoute(mRouter, mUserRouteCategory);
         }
 
         @Override
         public void setPlaybackInfo(PlaybackInfo info) {
-            MediaRouterJellybean.UserRouteInfo.setVolume(
-                    mUserRoute, info.volume);
-            MediaRouterJellybean.UserRouteInfo.setVolumeMax(
-                    mUserRoute, info.volumeMax);
-            MediaRouterJellybean.UserRouteInfo.setVolumeHandling(
-                    mUserRoute, info.volumeHandling);
-            MediaRouterJellybean.UserRouteInfo.setPlaybackStream(
-                    mUserRoute, info.playbackStream);
-            MediaRouterJellybean.UserRouteInfo.setPlaybackType(
-                    mUserRoute, info.playbackType);
+            MediaRouterApi16Impl.UserRouteInfo.setVolume(mUserRoute, info.volume);
+            MediaRouterApi16Impl.UserRouteInfo.setVolumeMax(mUserRoute, info.volumeMax);
+            MediaRouterApi16Impl.UserRouteInfo.setVolumeHandling(mUserRoute, info.volumeHandling);
+            MediaRouterApi16Impl.UserRouteInfo.setPlaybackStream(mUserRoute, info.playbackStream);
+            MediaRouterApi16Impl.UserRouteInfo.setPlaybackType(mUserRoute, info.playbackType);
 
             if (!mRegistered) {
                 mRegistered = true;
-                MediaRouterJellybean.UserRouteInfo.setVolumeCallback(mUserRoute,
-                        MediaRouterJellybean.createVolumeCallback(
-                                new VolumeCallbackWrapper(this)));
-                MediaRouterJellybean.UserRouteInfo.setRemoteControlClient(mUserRoute, mRcc);
+                MediaRouterApi16Impl.UserRouteInfo.setVolumeCallback(
+                        mUserRoute,
+                        MediaRouterApi16Impl.createVolumeCallback(new VolumeCallbackWrapper(this)));
+                MediaRouterApi16Impl.UserRouteInfo.setRemoteControlClient(mUserRoute, mRcc);
             }
         }
 
         private static final class VolumeCallbackWrapper
-                implements MediaRouterJellybean.VolumeCallback {
+                implements MediaRouterApi16Impl.VolumeCallback {
             // Unfortunately, the framework never unregisters its volume observer from
             // the audio service so the UserRouteInfo object may leak along with
             // any callbacks that we attach to it.  Use a weak reference to prevent
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/SystemMediaRouteProvider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/SystemMediaRouteProvider.java
index d8925fb..ab200f6 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/SystemMediaRouteProvider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/SystemMediaRouteProvider.java
@@ -208,12 +208,10 @@
         }
     }
 
-    /**
-     * Jellybean implementation.
-     */
+    /** Jellybean implementation. */
     @RequiresApi(16)
     static class JellybeanImpl extends SystemMediaRouteProvider
-            implements MediaRouterJellybean.Callback, MediaRouterJellybean.VolumeCallback {
+            implements MediaRouterApi16Impl.Callback, MediaRouterApi16Impl.VolumeCallback {
         private static final ArrayList<IntentFilter> LIVE_AUDIO_CONTROL_FILTERS;
 
         static {
@@ -255,19 +253,20 @@
         protected final ArrayList<UserRouteRecord> mUserRouteRecords =
                 new ArrayList<>();
 
-        private MediaRouterJellybean.SelectRouteWorkaround mSelectRouteWorkaround;
-        private MediaRouterJellybean.GetDefaultRouteWorkaround mGetDefaultRouteWorkaround;
+        private MediaRouterApi16Impl.SelectRouteWorkaround mSelectRouteWorkaround;
+        private MediaRouterApi16Impl.GetDefaultRouteWorkaround mGetDefaultRouteWorkaround;
 
         public JellybeanImpl(Context context, SyncCallback syncCallback) {
             super(context);
             mSyncCallback = syncCallback;
-            mRouter = MediaRouterJellybean.getMediaRouter(context);
+            mRouter = MediaRouterApi16Impl.getMediaRouter(context);
             mCallback = createCallback();
             mVolumeCallback = createVolumeCallback();
 
             Resources r = context.getResources();
-            mUserRouteCategory = MediaRouterJellybean.createRouteCategory(
-                    mRouter, r.getString(R.string.mr_user_route_category_name), false);
+            mUserRouteCategory =
+                    MediaRouterApi16Impl.createRouteCategory(
+                            mRouter, r.getString(R.string.mr_user_route_category_name), false);
 
             updateSystemRoutes();
         }
@@ -293,11 +292,11 @@
                 for (int i = 0; i < count; i++) {
                     String category = categories.get(i);
                     if (category.equals(MediaControlIntent.CATEGORY_LIVE_AUDIO)) {
-                        newRouteTypes |= MediaRouterJellybean.ROUTE_TYPE_LIVE_AUDIO;
+                        newRouteTypes |= MediaRouterApi16Impl.ROUTE_TYPE_LIVE_AUDIO;
                     } else if (category.equals(MediaControlIntent.CATEGORY_LIVE_VIDEO)) {
-                        newRouteTypes |= MediaRouterJellybean.ROUTE_TYPE_LIVE_VIDEO;
+                        newRouteTypes |= MediaRouterApi16Impl.ROUTE_TYPE_LIVE_VIDEO;
                     } else {
-                        newRouteTypes |= MediaRouterJellybean.ROUTE_TYPE_USER;
+                        newRouteTypes |= MediaRouterApi16Impl.ROUTE_TYPE_USER;
                     }
                 }
                 newActiveScan = request.isActiveScan();
@@ -320,8 +319,8 @@
         private void updateSystemRoutes() {
             updateCallback();
             boolean changed = false;
-            for (android.media.MediaRouter.RouteInfo route : MediaRouterJellybean.getRoutes(
-                    mRouter)) {
+            for (android.media.MediaRouter.RouteInfo route :
+                    MediaRouterApi16Impl.getRoutes(mRouter)) {
                 changed |= addSystemRouteNoPublish(route);
             }
             if (changed) {
@@ -387,7 +386,7 @@
                 int index = findSystemRouteRecord(route);
                 if (index >= 0) {
                     SystemRouteRecord record = mSystemRouteRecords.get(index);
-                    int newVolume = MediaRouterJellybean.RouteInfo.getVolume(route);
+                    int newVolume = MediaRouterApi16Impl.RouteInfo.getVolume(route);
                     if (newVolume != record.mRouteDescriptor.getVolume()) {
                         record.mRouteDescriptor =
                                 new MediaRouteDescriptor.Builder(record.mRouteDescriptor)
@@ -402,8 +401,9 @@
         @Override
         public void onRouteSelected(int type,
                 @NonNull android.media.MediaRouter.RouteInfo route) {
-            if (route != MediaRouterJellybean.getSelectedRoute(mRouter,
-                    MediaRouterJellybean.ALL_ROUTE_TYPES)) {
+            if (route
+                    != MediaRouterApi16Impl.getSelectedRoute(
+                            mRouter, MediaRouterApi16Impl.ALL_ROUTE_TYPES)) {
                 // The currently selected route has already changed so this callback
                 // is stale.  Drop it to prevent getting into sync loops.
                 return;
@@ -464,20 +464,20 @@
         public void onSyncRouteAdded(MediaRouter.RouteInfo route) {
             if (route.getProviderInstance() != this) {
                 android.media.MediaRouter.UserRouteInfo userRoute =
-                        MediaRouterJellybean.createUserRoute(mRouter, mUserRouteCategory);
+                        MediaRouterApi16Impl.createUserRoute(mRouter, mUserRouteCategory);
                 UserRouteRecord record = new UserRouteRecord(route, userRoute);
-                MediaRouterJellybean.RouteInfo.setTag(userRoute, record);
-                MediaRouterJellybean.UserRouteInfo.setVolumeCallback(userRoute, mVolumeCallback);
+                MediaRouterApi16Impl.RouteInfo.setTag(userRoute, record);
+                MediaRouterApi16Impl.UserRouteInfo.setVolumeCallback(userRoute, mVolumeCallback);
                 updateUserRouteProperties(record);
                 mUserRouteRecords.add(record);
-                MediaRouterJellybean.addUserRoute(mRouter, userRoute);
+                MediaRouterApi16Impl.addUserRoute(mRouter, userRoute);
             } else {
                 // If the newly added route is the counterpart of the currently selected
                 // route in the framework media router then ensure it is selected in
                 // the compat media router.
                 android.media.MediaRouter.RouteInfo routeObj =
-                        MediaRouterJellybean.getSelectedRoute(
-                                mRouter, MediaRouterJellybean.ALL_ROUTE_TYPES);
+                        MediaRouterApi16Impl.getSelectedRoute(
+                                mRouter, MediaRouterApi16Impl.ALL_ROUTE_TYPES);
                 int index = findSystemRouteRecord(routeObj);
                 if (index >= 0) {
                     SystemRouteRecord record = mSystemRouteRecords.get(index);
@@ -494,9 +494,9 @@
                 int index = findUserRouteRecord(route);
                 if (index >= 0) {
                     UserRouteRecord record = mUserRouteRecords.remove(index);
-                    MediaRouterJellybean.RouteInfo.setTag(record.mUserRoute, null);
-                    MediaRouterJellybean.UserRouteInfo.setVolumeCallback(record.mUserRoute, null);
-                    MediaRouterJellybean.removeUserRoute(mRouter, record.mUserRoute);
+                    MediaRouterApi16Impl.RouteInfo.setTag(record.mUserRoute, null);
+                    MediaRouterApi16Impl.UserRouteInfo.setVolumeCallback(record.mUserRoute, null);
+                    MediaRouterApi16Impl.removeUserRoute(mRouter, record.mUserRoute);
                 }
             }
         }
@@ -577,7 +577,7 @@
         }
 
         protected UserRouteRecord getUserRouteRecord(android.media.MediaRouter.RouteInfo route) {
-            Object tag = MediaRouterJellybean.RouteInfo.getTag(route);
+            Object tag = MediaRouterApi16Impl.RouteInfo.getTag(route);
             return tag instanceof UserRouteRecord ? (UserRouteRecord) tag : null;
         }
 
@@ -595,79 +595,74 @@
             // user routes.  We tolerate this by using an empty name string here but
             // such unnamed routes will be discarded by the media router upstream
             // (with a log message so we can track down the problem).
-            CharSequence name = MediaRouterJellybean.RouteInfo.getName(route, getContext());
+            CharSequence name = MediaRouterApi16Impl.RouteInfo.getName(route, getContext());
             return name != null ? name.toString() : "";
         }
 
         protected void onBuildSystemRouteDescriptor(SystemRouteRecord record,
                 MediaRouteDescriptor.Builder builder) {
-            int supportedTypes = MediaRouterJellybean.RouteInfo.getSupportedTypes(
-                    record.mRoute);
-            if ((supportedTypes & MediaRouterJellybean.ROUTE_TYPE_LIVE_AUDIO) != 0) {
+            int supportedTypes = MediaRouterApi16Impl.RouteInfo.getSupportedTypes(record.mRoute);
+            if ((supportedTypes & MediaRouterApi16Impl.ROUTE_TYPE_LIVE_AUDIO) != 0) {
                 builder.addControlFilters(LIVE_AUDIO_CONTROL_FILTERS);
             }
-            if ((supportedTypes & MediaRouterJellybean.ROUTE_TYPE_LIVE_VIDEO) != 0) {
+            if ((supportedTypes & MediaRouterApi16Impl.ROUTE_TYPE_LIVE_VIDEO) != 0) {
                 builder.addControlFilters(LIVE_VIDEO_CONTROL_FILTERS);
             }
 
-            builder.setPlaybackType(
-                    MediaRouterJellybean.RouteInfo.getPlaybackType(record.mRoute));
+            builder.setPlaybackType(MediaRouterApi16Impl.RouteInfo.getPlaybackType(record.mRoute));
             builder.setPlaybackStream(
-                    MediaRouterJellybean.RouteInfo.getPlaybackStream(record.mRoute));
-            builder.setVolume(
-                    MediaRouterJellybean.RouteInfo.getVolume(record.mRoute));
-            builder.setVolumeMax(
-                    MediaRouterJellybean.RouteInfo.getVolumeMax(record.mRoute));
+                    MediaRouterApi16Impl.RouteInfo.getPlaybackStream(record.mRoute));
+            builder.setVolume(MediaRouterApi16Impl.RouteInfo.getVolume(record.mRoute));
+            builder.setVolumeMax(MediaRouterApi16Impl.RouteInfo.getVolumeMax(record.mRoute));
             builder.setVolumeHandling(
-                    MediaRouterJellybean.RouteInfo.getVolumeHandling(record.mRoute));
+                    MediaRouterApi16Impl.RouteInfo.getVolumeHandling(record.mRoute));
         }
 
         protected void updateUserRouteProperties(UserRouteRecord record) {
-            MediaRouterJellybean.UserRouteInfo.setName(
-                    record.mUserRoute, record.mRoute.getName());
-            MediaRouterJellybean.UserRouteInfo.setPlaybackType(
+            MediaRouterApi16Impl.UserRouteInfo.setName(record.mUserRoute, record.mRoute.getName());
+            MediaRouterApi16Impl.UserRouteInfo.setPlaybackType(
                     record.mUserRoute, record.mRoute.getPlaybackType());
-            MediaRouterJellybean.UserRouteInfo.setPlaybackStream(
+            MediaRouterApi16Impl.UserRouteInfo.setPlaybackStream(
                     record.mUserRoute, record.mRoute.getPlaybackStream());
-            MediaRouterJellybean.UserRouteInfo.setVolume(
+            MediaRouterApi16Impl.UserRouteInfo.setVolume(
                     record.mUserRoute, record.mRoute.getVolume());
-            MediaRouterJellybean.UserRouteInfo.setVolumeMax(
+            MediaRouterApi16Impl.UserRouteInfo.setVolumeMax(
                     record.mUserRoute, record.mRoute.getVolumeMax());
-            MediaRouterJellybean.UserRouteInfo.setVolumeHandling(
+            MediaRouterApi16Impl.UserRouteInfo.setVolumeHandling(
                     record.mUserRoute, record.mRoute.getVolumeHandling());
         }
 
         protected void updateCallback() {
             if (mCallbackRegistered) {
                 mCallbackRegistered = false;
-                MediaRouterJellybean.removeCallback(mRouter, mCallback);
+                MediaRouterApi16Impl.removeCallback(mRouter, mCallback);
             }
 
             if (mRouteTypes != 0) {
                 mCallbackRegistered = true;
-                MediaRouterJellybean.addCallback(mRouter, mRouteTypes, mCallback);
+                MediaRouterApi16Impl.addCallback(mRouter, mRouteTypes, mCallback);
             }
         }
 
         protected android.media.MediaRouter.Callback createCallback() {
-            return MediaRouterJellybean.createCallback(this);
+            return MediaRouterApi16Impl.createCallback(this);
         }
 
         protected android.media.MediaRouter.VolumeCallback createVolumeCallback() {
-            return MediaRouterJellybean.createVolumeCallback(this);
+            return MediaRouterApi16Impl.createVolumeCallback(this);
         }
 
         protected void selectRoute(android.media.MediaRouter.RouteInfo route) {
             if (mSelectRouteWorkaround == null) {
-                mSelectRouteWorkaround = new MediaRouterJellybean.SelectRouteWorkaround();
+                mSelectRouteWorkaround = new MediaRouterApi16Impl.SelectRouteWorkaround();
             }
-            mSelectRouteWorkaround.selectRoute(mRouter,
-                    MediaRouterJellybean.ALL_ROUTE_TYPES, route);
+            mSelectRouteWorkaround.selectRoute(
+                    mRouter, MediaRouterApi16Impl.ALL_ROUTE_TYPES, route);
         }
 
         protected Object getDefaultRoute() {
             if (mGetDefaultRouteWorkaround == null) {
-                mGetDefaultRouteWorkaround = new MediaRouterJellybean.GetDefaultRouteWorkaround();
+                mGetDefaultRouteWorkaround = new MediaRouterApi16Impl.GetDefaultRouteWorkaround();
             }
             return mGetDefaultRouteWorkaround.getDefaultRoute(mRouter);
         }
@@ -711,24 +706,22 @@
 
             @Override
             public void onSetVolume(int volume) {
-                MediaRouterJellybean.RouteInfo.requestSetVolume(mRoute, volume);
+                MediaRouterApi16Impl.RouteInfo.requestSetVolume(mRoute, volume);
             }
 
             @Override
             public void onUpdateVolume(int delta) {
-                MediaRouterJellybean.RouteInfo.requestUpdateVolume(mRoute, delta);
+                MediaRouterApi16Impl.RouteInfo.requestUpdateVolume(mRoute, delta);
             }
         }
     }
 
-    /**
-     * Jellybean MR1 implementation.
-     */
+    /** Jellybean MR1 implementation. */
     @RequiresApi(17)
     private static class JellybeanMr1Impl extends JellybeanImpl
-            implements MediaRouterJellybeanMr1.Callback {
-        private MediaRouterJellybeanMr1.ActiveScanWorkaround mActiveScanWorkaround;
-        private MediaRouterJellybeanMr1.IsConnectingWorkaround mIsConnectingWorkaround;
+            implements MediaRouterApi17Impl.Callback {
+        private MediaRouterApi17Impl.ActiveScanWorkaround mActiveScanWorkaround;
+        private MediaRouterApi17Impl.IsConnectingWorkaround mIsConnectingWorkaround;
 
         public JellybeanMr1Impl(Context context, SyncCallback syncCallback) {
             super(context, syncCallback);
@@ -741,7 +734,7 @@
             if (index >= 0) {
                 SystemRouteRecord record = mSystemRouteRecords.get(index);
                 Display newPresentationDisplay =
-                        MediaRouterJellybeanMr1.RouteInfo.getPresentationDisplay(route);
+                        MediaRouterApi17Impl.RouteInfo.getPresentationDisplay(route);
                 int newPresentationDisplayId = (newPresentationDisplay != null
                         ? newPresentationDisplay.getDisplayId() : -1);
                 if (newPresentationDisplayId
@@ -760,7 +753,7 @@
                 MediaRouteDescriptor.Builder builder) {
             super.onBuildSystemRouteDescriptor(record, builder);
 
-            if (!MediaRouterJellybeanMr1.RouteInfo.isEnabled(record.mRoute)) {
+            if (!MediaRouterApi17Impl.RouteInfo.isEnabled(record.mRoute)) {
                 builder.setEnabled(false);
             }
 
@@ -769,7 +762,7 @@
             }
 
             Display presentationDisplay =
-                    MediaRouterJellybeanMr1.RouteInfo.getPresentationDisplay(record.mRoute);
+                    MediaRouterApi17Impl.RouteInfo.getPresentationDisplay(record.mRoute);
             if (presentationDisplay != null) {
                 builder.setPresentationDisplayId(presentationDisplay.getDisplayId());
             }
@@ -780,20 +773,20 @@
             super.updateCallback();
 
             if (mActiveScanWorkaround == null) {
-                mActiveScanWorkaround = new MediaRouterJellybeanMr1.ActiveScanWorkaround(
-                        getContext(), getHandler());
+                mActiveScanWorkaround =
+                        new MediaRouterApi17Impl.ActiveScanWorkaround(getContext(), getHandler());
             }
             mActiveScanWorkaround.setActiveScanRouteTypes(mActiveScan ? mRouteTypes : 0);
         }
 
         @Override
         protected android.media.MediaRouter.Callback createCallback() {
-            return MediaRouterJellybeanMr1.createCallback(this);
+            return MediaRouterApi17Impl.createCallback(this);
         }
 
         protected boolean isConnecting(SystemRouteRecord record) {
             if (mIsConnectingWorkaround == null) {
-                mIsConnectingWorkaround = new MediaRouterJellybeanMr1.IsConnectingWorkaround();
+                mIsConnectingWorkaround = new MediaRouterApi17Impl.IsConnectingWorkaround();
             }
             return mIsConnectingWorkaround.isConnecting(record.mRoute);
         }
@@ -823,7 +816,7 @@
         @DoNotInline
         @Override
         protected void selectRoute(android.media.MediaRouter.RouteInfo route) {
-            MediaRouterJellybean.selectRoute(mRouter, MediaRouterJellybean.ALL_ROUTE_TYPES, route);
+            MediaRouterApi16Impl.selectRoute(mRouter, MediaRouterApi16Impl.ALL_ROUTE_TYPES, route);
         }
 
         @DoNotInline
@@ -843,7 +836,7 @@
         @Override
         protected void updateCallback() {
             if (mCallbackRegistered) {
-                MediaRouterJellybean.removeCallback(mRouter, mCallback);
+                MediaRouterApi16Impl.removeCallback(mRouter, mCallback);
             }
 
             mCallbackRegistered = true;
diff --git a/paging/paging-testing/build.gradle b/paging/paging-testing/build.gradle
index c01f6cc..f605fc4 100644
--- a/paging/paging-testing/build.gradle
+++ b/paging/paging-testing/build.gradle
@@ -15,24 +15,41 @@
  */
 
 import androidx.build.LibraryType
+import androidx.build.PlatformIdentifier
 import androidx.build.Publish
 
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
-    id("org.jetbrains.kotlin.android")
 }
 
-dependencies {
-    api(libs.kotlinStdlib)
-    implementation(project(":paging:paging-common"))
+androidXMultiplatform {
+    jvm()
+    mac()
+    linux()
+    ios()
+    android()
 
-    testImplementation(libs.junit)
-    testImplementation(libs.kotlinCoroutinesTest)
-    testImplementation((libs.kotlinCoroutinesAndroid))
-    testImplementation(project(":internal-testutils-paging"))
-    testImplementation(libs.kotlinTest)
-    testImplementation(libs.truth)
+    defaultPlatform(PlatformIdentifier.ANDROID)
+
+    sourceSets {
+        androidMain {
+            dependencies {
+                api(libs.kotlinStdlib)
+                implementation(project(":paging:paging-common"))
+            }
+        }
+        androidTest {
+            dependencies {
+                implementation(libs.junit)
+                implementation(libs.kotlinCoroutinesTest)
+                implementation((libs.kotlinCoroutinesAndroid))
+                implementation(project(":internal-testutils-paging"))
+                implementation(libs.kotlinTest)
+                implementation(libs.truth)
+            }
+        }
+    }
 }
 
 androidx {
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/LoadErrorHandler.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/LoadErrorHandler.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/LoadErrorHandler.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/LoadErrorHandler.kt
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/PagerFlowSnapshot.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/PagerFlowSnapshot.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/PagerFlowSnapshot.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/PagerFlowSnapshot.kt
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/SnapshotLoader.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/SnapshotLoader.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/SnapshotLoader.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/SnapshotLoader.kt
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/StaticListPagingSource.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/StaticListPagingSource.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/StaticListPagingSource.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/StaticListPagingSource.kt
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/StaticListPagingSourceFactory.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/StaticListPagingSourceFactory.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/StaticListPagingSourceFactory.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/StaticListPagingSourceFactory.kt
diff --git a/paging/paging-testing/src/main/java/androidx/paging/testing/TestPager.kt b/paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/TestPager.kt
similarity index 100%
rename from paging/paging-testing/src/main/java/androidx/paging/testing/TestPager.kt
rename to paging/paging-testing/src/androidMain/kotlin/androidx/paging/testing/TestPager.kt
diff --git a/paging/paging-testing/src/test/kotlin/androidx/paging/testing/PagerFlowSnapshotTest.kt b/paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/PagerFlowSnapshotTest.kt
similarity index 100%
rename from paging/paging-testing/src/test/kotlin/androidx/paging/testing/PagerFlowSnapshotTest.kt
rename to paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/PagerFlowSnapshotTest.kt
diff --git a/paging/paging-testing/src/test/kotlin/androidx/paging/testing/StaticListPagingSourceFactoryTest.kt b/paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/StaticListPagingSourceFactoryTest.kt
similarity index 100%
rename from paging/paging-testing/src/test/kotlin/androidx/paging/testing/StaticListPagingSourceFactoryTest.kt
rename to paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/StaticListPagingSourceFactoryTest.kt
diff --git a/paging/paging-testing/src/test/kotlin/androidx/paging/testing/StaticListPagingSourceTest.kt b/paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/StaticListPagingSourceTest.kt
similarity index 100%
rename from paging/paging-testing/src/test/kotlin/androidx/paging/testing/StaticListPagingSourceTest.kt
rename to paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/StaticListPagingSourceTest.kt
diff --git a/paging/paging-testing/src/test/kotlin/androidx/paging/testing/TestPagerTest.kt b/paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/TestPagerTest.kt
similarity index 100%
rename from paging/paging-testing/src/test/kotlin/androidx/paging/testing/TestPagerTest.kt
rename to paging/paging-testing/src/androidTest/kotlin/androidx/paging/testing/TestPagerTest.kt
diff --git a/playground-common/playground.properties b/playground-common/playground.properties
index 866f0cb9..1734c38 100644
--- a/playground-common/playground.properties
+++ b/playground-common/playground.properties
@@ -26,5 +26,5 @@
 # Disable docs
 androidx.enableDocumentation=false
 androidx.playground.snapshotBuildId=10533165
-androidx.playground.metalavaBuildId=10499661
-androidx.studio.type=playground
+androidx.playground.metalavaBuildId=10621923
+androidx.studio.type=playground
\ No newline at end of file
diff --git a/privacysandbox/plugins/plugins-privacysandbox-library/build.gradle b/privacysandbox/plugins/plugins-privacysandbox-library/build.gradle
index 6b246b6..a183ced 100644
--- a/privacysandbox/plugins/plugins-privacysandbox-library/build.gradle
+++ b/privacysandbox/plugins/plugins-privacysandbox-library/build.gradle
@@ -7,19 +7,30 @@
     id("java-gradle-plugin")
 }
 
+configurations {
+    // Config for plugin classpath to be used during tests
+    testPlugin {
+        canBeConsumed = false
+        canBeResolved = true
+    }
+}
+
 SdkResourceGenerator.generateForHostTest(project)
 apply from: "../../../buildSrc/kotlin-dsl-dependency.gradle"
 
 dependencies {
     implementation(findGradleKotlinDsl())
     implementation("com.android.tools.build:gradle-api:8.0.0-alpha07")
-    implementation(libs.androidGradlePluginz)
     implementation(libs.kotlinStdlib)
-    implementation(libs.kspApi)
-    implementation(libs.kspGradlePluginz)
+    compileOnly(libs.androidGradlePluginz)
+    compileOnly(libs.kspGradlePluginz)
 
     testImplementation(libs.junit)
     testImplementation(project(":internal-testutils-gradle-plugin"))
+
+    testPlugin("com.android.tools.build:gradle:8.0.0-alpha07")
+    testPlugin(libs.kotlinGradlePluginz)
+    testPlugin(libs.kspGradlePluginz)
 }
 
 gradlePlugin {
@@ -31,6 +42,14 @@
     }
 }
 
+// Configure the generating task of plugin-under-test-metadata.properties to
+// include additional dependencies for the injected plugin classpath that
+// are not present in the main runtime dependencies. This allows us to test
+// the KAPT / KSP plugins while keeping a compileOnly dep on the main source.
+tasks.withType(PluginUnderTestMetadata.class).named("pluginUnderTestMetadata").configure {
+    it.pluginClasspath.from(configurations.testPlugin)
+}
+
 tasks {
     validatePlugins {
         failOnWarning.set(true)
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/build.gradle b/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
index 3c7ef5e..8f40ab8 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
+++ b/privacysandbox/sdkruntime/sdkruntime-client/build.gradle
@@ -117,6 +117,10 @@
     androidTestImplementation(libs.dexmakerMockitoInline, excludes.bytebuddy) // DexMaker has it"s own MockMaker
 
     androidTestBundleDex(project(":privacysandbox:sdkruntime:test-sdks:current"))
+    androidTestBundleDex(project(":privacysandbox:sdkruntime:test-sdks:v1"))
+    androidTestBundleDex(project(":privacysandbox:sdkruntime:test-sdks:v2"))
+    // V3 was released as V4 (original release postponed)
+    androidTestBundleDex(project(":privacysandbox:sdkruntime:test-sdks:v4"))
 }
 
 android {
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdkTable.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdkTable.xml
index 68b686a..1b159d4 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdkTable.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdkTable.xml
@@ -15,24 +15,28 @@
   -->
 <runtime-enabled-sdk-table>
     <runtime-enabled-sdk>
-        <compat-config-path>RuntimeEnabledSdks/V1/CompatSdkConfig.xml</compat-config-path>
-        <package-name>androidx.privacysandbox.sdkruntime.test.v1</package-name>
+        <compat-config-path>RuntimeEnabledSdks/current.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.current</package-name>
         <version-major>42</version-major>
     </runtime-enabled-sdk>
     <runtime-enabled-sdk>
-        <compat-config-path>RuntimeEnabledSdks/V2/CompatSdkConfig.xml</compat-config-path>
-        <package-name>androidx.privacysandbox.sdkruntime.test.v2</package-name>
+        <compat-config-path>RuntimeEnabledSdks/currentWithResources.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.currentWithResources</package-name>
     </runtime-enabled-sdk>
     <runtime-enabled-sdk>
-        <compat-config-path>RuntimeEnabledSdks/V3/CompatSdkConfig.xml</compat-config-path>
-        <package-name>androidx.privacysandbox.sdkruntime.test.v3</package-name>
+        <compat-config-path>RuntimeEnabledSdks/invalidEntryPoint.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.invalidEntryPoint</package-name>
     </runtime-enabled-sdk>
     <runtime-enabled-sdk>
-        <compat-config-path>RuntimeEnabledSdks/V4/CompatSdkConfig.xml</compat-config-path>
-        <package-name>androidx.privacysandbox.sdkruntime.test.v4</package-name>
+        <compat-config-path>RuntimeEnabledSdks/v1.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.v1</package-name>
     </runtime-enabled-sdk>
     <runtime-enabled-sdk>
-        <compat-config-path>RuntimeEnabledSdks/InvalidEntryPointSdkConfig.xml</compat-config-path>
-        <package-name>androidx.privacysandbox.sdkruntime.test.invalidEntryPoint</package-name>
+        <compat-config-path>RuntimeEnabledSdks/v2.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.v2</package-name>
+    </runtime-enabled-sdk>
+    <runtime-enabled-sdk>
+        <compat-config-path>RuntimeEnabledSdks/v4.xml</compat-config-path>
+        <package-name>androidx.privacysandbox.sdkruntime.testsdk.v4</package-name>
     </runtime-enabled-sdk>
 </runtime-enabled-sdk-table>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkCode.md b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkCode.md
deleted file mode 100644
index a2f1cbf..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkCode.md
+++ /dev/null
@@ -1,125 +0,0 @@
-Test sdk that was built with V1 library.
-
-DO NOT RECOMPILE WITH ANY CHANGES TO LIBRARY CLASSES.
-Main purpose of that provider is to test that old core versions could be loaded by new client.
-
-classes.dex built from:
-
-1) androidx.privacysandbox.sdkruntime.core.Versions
-@Keep
-object Versions {
-
-    const val API_VERSION = 1
-
-    @JvmField
-    var CLIENT_VERSION = -1
-
-    @JvmStatic
-    fun handShake(clientVersion: Int): Int {
-        CLIENT_VERSION = clientVersion
-        return API_VERSION
-    }
-}
-
-2) androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
-abstract class SandboxedSdkProviderCompat {
-    var context: Context? = null
-        private set
-
-    fun attachContext(context: Context) {
-        check(this.context == null) { "Context already set" }
-        this.context = context
-    }
-
-    @Throws(LoadSdkCompatException::class)
-    abstract fun onLoadSdk(params: Bundle): SandboxedSdkCompat
-
-    open fun beforeUnloadSdk() {}
-
-    abstract fun getView(
-            windowContext: Context,
-            params: Bundle,
-            width: Int,
-            height: Int
-    ): View
-}
-
-3) androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
-sealed class SandboxedSdkCompat {
-
-    abstract fun getInterface(): IBinder?
-
-    private class CompatImpl(private val mInterface: IBinder) : SandboxedSdkCompat() {
-        override fun getInterface(): IBinder? {
-            return mInterface
-        }
-    }
-
-    companion object {
-        @JvmStatic
-        fun create(binder: IBinder): SandboxedSdkCompat {
-            return CompatImpl(binder)
-        }
-    }
-}
-
-4) androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
-class LoadSdkCompatException : Exception {
-
-    val loadSdkErrorCode: Int
-
-    val extraInformation: Bundle
-
-    @JvmOverloads
-    constructor(
-            loadSdkErrorCode: Int,
-            message: String?,
-            cause: Throwable?,
-            extraInformation: Bundle = Bundle()
-    ) : super(message, cause) {
-        this.loadSdkErrorCode = loadSdkErrorCode
-        this.extraInformation = extraInformation
-    }
-
-    constructor(
-            cause: Throwable,
-            extraInfo: Bundle
-    ) : this(LOAD_SDK_SDK_DEFINED_ERROR, "", cause, extraInfo)
-
-    companion object {
-        const val LOAD_SDK_SDK_DEFINED_ERROR = 102
-    }
-}
-
-5) androidx.privacysandbox.sdkruntime.test.v1.CompatProvider
-class CompatProvider : SandboxedSdkProviderCompat() {
-
-    @JvmField
-    val onLoadSdkBinder = Binder()
-
-    @JvmField
-    var lastOnLoadSdkParams: Bundle? = null
-
-    @JvmField
-    var isBeforeUnloadSdkCalled = false
-
-    @Throws(LoadSdkCompatException::class)
-    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
-        lastOnLoadSdkParams = params
-        if (params.getBoolean("needFail", false)) {
-            throw LoadSdkCompatException(RuntimeException(), params)
-        }
-        return SandboxedSdkCompat.create(onLoadSdkBinder)
-    }
-
-    override fun beforeUnloadSdk() {
-        isBeforeUnloadSdkCalled = true
-    }
-
-    override fun getView(
-            windowContext: Context, params: Bundle, width: Int,
-            height: Int
-    ): View {
-        return View(windowContext)
-    }
-}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkConfig.xml
deleted file mode 100644
index dfeca90..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/CompatSdkConfig.xml
+++ /dev/null
@@ -1,20 +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.
-  -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v1.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V1/classes.dex</dex-path>
-    <java-resources-root-path>RuntimeEnabledSdks/V1/javaresources</java-resources-root-path>
-</compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/classes.dex b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/classes.dex
deleted file mode 100644
index a6da7e9..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/classes.dex
+++ /dev/null
Binary files differ
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkCode.md b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkCode.md
deleted file mode 100644
index 5dd9c4f..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkCode.md
+++ /dev/null
@@ -1,204 +0,0 @@
-Test sdk that was built with V2 library.
-
-DO NOT RECOMPILE WITH ANY CHANGES TO LIBRARY CLASSES.
-Main purpose of that provider is to test that old core versions could be loaded by new client.
-
-classes.dex built from:
-
-1) androidx.privacysandbox.sdkruntime.core.Versions
-@Keep
-object Versions {
-
-    const val API_VERSION = 2
-
-    @JvmField
-    var CLIENT_VERSION: Int? = null
-
-    @JvmStatic
-    fun handShake(clientVersion: Int): Int {
-        CLIENT_VERSION = clientVersion
-        return API_VERSION
-    }
-}
-
-2) androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
-abstract class SandboxedSdkProviderCompat {
-    var context: Context? = null
-        private set
-
-    fun attachContext(context: Context) {
-        check(this.context == null) { "Context already set" }
-        this.context = context
-    }
-
-    @Throws(LoadSdkCompatException::class)
-    abstract fun onLoadSdk(params: Bundle): SandboxedSdkCompat
-
-    open fun beforeUnloadSdk() {}
-
-    abstract fun getView(
-            windowContext: Context,
-            params: Bundle,
-            width: Int,
-            height: Int
-    ): View
-}
-
-3) androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
-class SandboxedSdkCompat private constructor(
-   private val sdkImpl: SandboxedSdkImpl
-) {
-
-   constructor(sdkInterface: IBinder) : this(sdkInterface, sdkInfo = null)
-
-   @Keep
-   constructor(
-      sdkInterface: IBinder,
-      sdkInfo: SandboxedSdkInfo?
-   ) : this(CompatImpl(sdkInterface, sdkInfo))
-
-   fun getInterface() = sdkImpl.getInterface()
-
-   fun getSdkInfo(): SandboxedSdkInfo? = sdkImpl.getSdkInfo()
-
-   internal interface SandboxedSdkImpl {
-      fun getInterface(): IBinder?
-      fun getSdkInfo(): SandboxedSdkInfo?
-   }
-
-   private class CompatImpl(
-      private val sdkInterface: IBinder,
-      private val sdkInfo: SandboxedSdkInfo?
-   ) : SandboxedSdkImpl {
-
-      override fun getInterface(): IBinder {
-         return sdkInterface
-      }
-
-      override fun getSdkInfo(): SandboxedSdkInfo? = sdkInfo
-   }
-}
-
-4) androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
-class LoadSdkCompatException : Exception {
-
-    val loadSdkErrorCode: Int
-
-    val extraInformation: Bundle
-
-    @JvmOverloads
-    constructor(
-            loadSdkErrorCode: Int,
-            message: String?,
-            cause: Throwable?,
-            extraInformation: Bundle = Bundle()
-    ) : super(message, cause) {
-        this.loadSdkErrorCode = loadSdkErrorCode
-        this.extraInformation = extraInformation
-    }
-
-    constructor(
-            cause: Throwable,
-            extraInfo: Bundle
-    ) : this(LOAD_SDK_SDK_DEFINED_ERROR, "", cause, extraInfo)
-
-    companion object {
-        const val LOAD_SDK_SDK_DEFINED_ERROR = 102
-    }
-}
-
-5) androidx.privacysandbox.sdkruntime.core.SandboxedSdkInfo
-class SandboxedSdkInfo(
-    val name: String,
-    val version: Long
-) {
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as SandboxedSdkInfo
-        if (name != other.name) return false
-        if (version != other.version) return false
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = name.hashCode()
-        result = 31 * result + version.hashCode()
-        return result
-    }
-}
-
-6) androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
-class SdkSandboxControllerCompat internal constructor(
-    private val controllerImpl: SandboxControllerImpl
-) {
-    fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-        controllerImpl.getSandboxedSdks()
-
-    interface SandboxControllerImpl {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat>
-    }
-
-    companion object {
-        private var localImpl: SandboxControllerImpl? = null
-        @JvmStatic
-        fun from(context: Context): SdkSandboxControllerCompat {
-            val loadedLocally = Versions.CLIENT_VERSION != null
-            if (loadedLocally) {
-                val implFromClient = localImpl
-                if (implFromClient != null) {
-                    return SdkSandboxControllerCompat(implFromClient)
-                }
-            }
-            throw IllegalStateException("Should be loaded locally")
-        }
-        @JvmStatic
-        @Keep
-        fun injectLocalImpl(impl: SandboxControllerImpl) {
-            check(localImpl == null) { "Local implementation already injected" }
-            localImpl = impl
-        }
-    }
-}
-
-7) androidx.privacysandbox.sdkruntime.test.v2.CompatProvider
-class CompatProvider : SandboxedSdkProviderCompat() {
-
-    @JvmField
-    var onLoadSdkBinder: Binder? = null
-
-    @JvmField
-    var lastOnLoadSdkParams: Bundle? = null
-
-    @JvmField
-    var isBeforeUnloadSdkCalled = false
-
-    @Throws(LoadSdkCompatException::class)
-    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
-        val result = SdkImpl(context!!)
-        onLoadSdkBinder = result
-        if (params.getBoolean("needFail", false)) {
-            throw LoadSdkCompatException(RuntimeException(), params)
-        }
-        return SandboxedSdkCompat(result)
-    }
-
-    override fun beforeUnloadSdk() {
-        isBeforeUnloadSdkCalled = true
-    }
-
-    override fun getView(
-            windowContext: Context, params: Bundle, width: Int,
-            height: Int
-    ): View {
-        return View(windowContext)
-    }
-
-    class SdkImpl(
-        private val context: Context
-    ) : Binder() {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-            SdkSandboxControllerCompat.from(context).getSandboxedSdks()
-    }
-}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/classes.dex b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/classes.dex
deleted file mode 100644
index 52702de..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/classes.dex
+++ /dev/null
Binary files differ
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkCode.md b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkCode.md
deleted file mode 100644
index 750e2a3..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkCode.md
+++ /dev/null
@@ -1,263 +0,0 @@
-Test sdk that was built with V3 library.
-
-DO NOT RECOMPILE WITH ANY CHANGES TO LIBRARY CLASSES.
-Main purpose of that provider is to test that old core versions could be loaded by new client.
-
-classes.dex built from:
-
-1) androidx.privacysandbox.sdkruntime.core.Versions
-@Keep
-object Versions {
-
-    const val API_VERSION = 3
-
-    @JvmField
-    var CLIENT_VERSION: Int? = null
-
-    @JvmStatic
-    fun handShake(clientVersion: Int): Int {
-        CLIENT_VERSION = clientVersion
-        return API_VERSION
-    }
-}
-
-2) androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
-abstract class SandboxedSdkProviderCompat {
-    var context: Context? = null
-        private set
-
-    fun attachContext(context: Context) {
-        check(this.context == null) { "Context already set" }
-        this.context = context
-    }
-
-    @Throws(LoadSdkCompatException::class)
-    abstract fun onLoadSdk(params: Bundle): SandboxedSdkCompat
-
-    open fun beforeUnloadSdk() {}
-
-    abstract fun getView(
-            windowContext: Context,
-            params: Bundle,
-            width: Int,
-            height: Int
-    ): View
-}
-
-3) androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
-class SandboxedSdkCompat private constructor(
-   private val sdkImpl: SandboxedSdkImpl
-) {
-
-   constructor(sdkInterface: IBinder) : this(sdkInterface, sdkInfo = null)
-
-   @Keep
-   constructor(
-      sdkInterface: IBinder,
-      sdkInfo: SandboxedSdkInfo?
-   ) : this(CompatImpl(sdkInterface, sdkInfo))
-
-   fun getInterface() = sdkImpl.getInterface()
-
-   fun getSdkInfo(): SandboxedSdkInfo? = sdkImpl.getSdkInfo()
-
-   internal interface SandboxedSdkImpl {
-      fun getInterface(): IBinder?
-      fun getSdkInfo(): SandboxedSdkInfo?
-   }
-
-   private class CompatImpl(
-      private val sdkInterface: IBinder,
-      private val sdkInfo: SandboxedSdkInfo?
-   ) : SandboxedSdkImpl {
-
-      override fun getInterface(): IBinder {
-         return sdkInterface
-      }
-
-      override fun getSdkInfo(): SandboxedSdkInfo? = sdkInfo
-   }
-}
-
-4) androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
-class LoadSdkCompatException : Exception {
-
-    val loadSdkErrorCode: Int
-
-    val extraInformation: Bundle
-
-    @JvmOverloads
-    constructor(
-            loadSdkErrorCode: Int,
-            message: String?,
-            cause: Throwable?,
-            extraInformation: Bundle = Bundle()
-    ) : super(message, cause) {
-        this.loadSdkErrorCode = loadSdkErrorCode
-        this.extraInformation = extraInformation
-    }
-
-    constructor(
-            cause: Throwable,
-            extraInfo: Bundle
-    ) : this(LOAD_SDK_SDK_DEFINED_ERROR, "", cause, extraInfo)
-
-    companion object {
-        const val LOAD_SDK_SDK_DEFINED_ERROR = 102
-    }
-}
-
-5) androidx.privacysandbox.sdkruntime.core.SandboxedSdkInfo
-class SandboxedSdkInfo(
-    val name: String,
-    val version: Long
-) {
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as SandboxedSdkInfo
-        if (name != other.name) return false
-        if (version != other.version) return false
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = name.hashCode()
-        result = 31 * result + version.hashCode()
-        return result
-    }
-}
-
-6) androidx.privacysandbox.sdkruntime.core.activity.ActivityHolder
-interface ActivityHolder : LifecycleOwner {
-    fun getActivity(): Activity
-    fun getOnBackPressedDispatcher(): OnBackPressedDispatcher
-}
-
-7) androidx.privacysandbox.sdkruntime.core.activity.SdkSandboxActivityHandlerCompat
-interface SdkSandboxActivityHandlerCompat {
-    fun onActivityCreated(activityHolder: ActivityHolder)
-}
-
-8) androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
-class SdkSandboxControllerCompat internal constructor(
-    private val controllerImpl: SandboxControllerImpl
-) {
-    fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-        controllerImpl.getSandboxedSdks()
-
-    fun registerSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat):
-        IBinder = controllerImpl.registerSdkSandboxActivityHandler(handlerCompat)
-
-    fun unregisterSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat) =
-        controllerImpl.unregisterSdkSandboxActivityHandler(handlerCompat)
-
-    interface SandboxControllerImpl {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat>
-        fun registerSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat):
-            IBinder
-        fun unregisterSdkSandboxActivityHandler(
-            handlerCompat: SdkSandboxActivityHandlerCompat
-        )
-    }
-
-    companion object {
-        private var localImpl: SandboxControllerImpl? = null
-        @JvmStatic
-        fun from(context: Context): SdkSandboxControllerCompat {
-            val clientVersion = Versions.CLIENT_VERSION
-            if (clientVersion != null) {
-                val implFromClient = localImpl
-                if (implFromClient != null) {
-                    return SdkSandboxControllerCompat(LocalImpl(implFromClient, clientVersion))
-                }
-            }
-            throw IllegalStateException("Should be loaded locally")
-        }
-        @JvmStatic
-        @Keep
-        fun injectLocalImpl(impl: SandboxControllerImpl) {
-            check(localImpl == null) { "Local implementation already injected" }
-            localImpl = impl
-        }
-    }
-}
-
-9) androidx.privacysandbox.sdkruntime.core.controller.impl.LocalImpl
-internal class LocalImpl(
-    private val implFromClient: SdkSandboxControllerCompat.SandboxControllerImpl,
-    private val clientVersion: Int
-) : SdkSandboxControllerCompat.SandboxControllerImpl {
-    override fun getSandboxedSdks(): List<SandboxedSdkCompat> {
-        return implFromClient.getSandboxedSdks()
-    }
-
-    override fun registerSdkSandboxActivityHandler(
-        handlerCompat: SdkSandboxActivityHandlerCompat
-    ): IBinder {
-        if (clientVersion < 3) {
-            throw UnsupportedOperationException(
-                "Client library version doesn't support SdkActivities"
-            )
-        }
-        return implFromClient.registerSdkSandboxActivityHandler(handlerCompat)
-    }
-
-    override fun unregisterSdkSandboxActivityHandler(
-        handlerCompat: SdkSandboxActivityHandlerCompat
-    ) {
-        if (clientVersion < 3) {
-            throw UnsupportedOperationException(
-                "Client library version doesn't support SdkActivities"
-            )
-        }
-        implFromClient.unregisterSdkSandboxActivityHandler(handlerCompat)
-    }
-}
-
-10) androidx.privacysandbox.sdkruntime.test.v3.CompatProvider
-class CompatProvider : SandboxedSdkProviderCompat() {
-
-    @JvmField
-    var onLoadSdkBinder: Binder? = null
-
-    @JvmField
-    var lastOnLoadSdkParams: Bundle? = null
-
-    @JvmField
-    var isBeforeUnloadSdkCalled = false
-
-    @Throws(LoadSdkCompatException::class)
-    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
-        val result = SdkImpl(context!!)
-        onLoadSdkBinder = result
-        if (params.getBoolean("needFail", false)) {
-            throw LoadSdkCompatException(RuntimeException(), params)
-        }
-        return SandboxedSdkCompat(result)
-    }
-
-    override fun beforeUnloadSdk() {
-        isBeforeUnloadSdkCalled = true
-    }
-
-    override fun getView(
-            windowContext: Context, params: Bundle, width: Int,
-            height: Int
-    ): View {
-        return View(windowContext)
-    }
-
-    class SdkImpl(
-        private val context: Context
-    ) : Binder() {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-            SdkSandboxControllerCompat.from(context).getSandboxedSdks()
-        fun registerSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat): IBinder =
-            SdkSandboxControllerCompat.from(context).registerSdkSandboxActivityHandler(handler)
-        fun unregisterSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat) {
-            SdkSandboxControllerCompat.from(context).unregisterSdkSandboxActivityHandler(handler)
-        }
-    }
-}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkConfig.xml
deleted file mode 100644
index 5c23d99..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/CompatSdkConfig.xml
+++ /dev/null
@@ -1,19 +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.
-  -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v3.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V3/classes.dex</dex-path>
-</compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/classes.dex b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/classes.dex
deleted file mode 100644
index 9740bdf..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V3/classes.dex
+++ /dev/null
Binary files differ
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkCode.md b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkCode.md
deleted file mode 100644
index b813a6d..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkCode.md
+++ /dev/null
@@ -1,288 +0,0 @@
-Test sdk that was built with V4 library.
-
-DO NOT RECOMPILE WITH ANY CHANGES TO LIBRARY CLASSES.
-Main purpose of that provider is to test that old core versions could be loaded by new client.
-
-classes.dex built from:
-
-1) androidx.privacysandbox.sdkruntime.core.Versions
-@Keep
-object Versions {
-
-    const val API_VERSION = 4
-
-    @JvmField
-    var CLIENT_VERSION: Int? = null
-
-    @JvmStatic
-    fun handShake(clientVersion: Int): Int {
-        CLIENT_VERSION = clientVersion
-        return API_VERSION
-    }
-}
-
-2) androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
-abstract class SandboxedSdkProviderCompat {
-    var context: Context? = null
-        private set
-
-    fun attachContext(context: Context) {
-        check(this.context == null) { "Context already set" }
-        this.context = context
-    }
-
-    @Throws(LoadSdkCompatException::class)
-    abstract fun onLoadSdk(params: Bundle): SandboxedSdkCompat
-
-    open fun beforeUnloadSdk() {}
-
-    abstract fun getView(
-            windowContext: Context,
-            params: Bundle,
-            width: Int,
-            height: Int
-    ): View
-}
-
-3) androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
-class SandboxedSdkCompat private constructor(
-   private val sdkImpl: SandboxedSdkImpl
-) {
-
-   constructor(sdkInterface: IBinder) : this(sdkInterface, sdkInfo = null)
-
-   @Keep
-   constructor(
-      sdkInterface: IBinder,
-      sdkInfo: SandboxedSdkInfo?
-   ) : this(CompatImpl(sdkInterface, sdkInfo))
-
-   fun getInterface() = sdkImpl.getInterface()
-
-   fun getSdkInfo(): SandboxedSdkInfo? = sdkImpl.getSdkInfo()
-
-   internal interface SandboxedSdkImpl {
-      fun getInterface(): IBinder?
-      fun getSdkInfo(): SandboxedSdkInfo?
-   }
-
-   private class CompatImpl(
-      private val sdkInterface: IBinder,
-      private val sdkInfo: SandboxedSdkInfo?
-   ) : SandboxedSdkImpl {
-
-      override fun getInterface(): IBinder {
-         return sdkInterface
-      }
-
-      override fun getSdkInfo(): SandboxedSdkInfo? = sdkInfo
-   }
-}
-
-4) androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
-class LoadSdkCompatException : Exception {
-
-    val loadSdkErrorCode: Int
-
-    val extraInformation: Bundle
-
-    @JvmOverloads
-    constructor(
-            loadSdkErrorCode: Int,
-            message: String?,
-            cause: Throwable?,
-            extraInformation: Bundle = Bundle()
-    ) : super(message, cause) {
-        this.loadSdkErrorCode = loadSdkErrorCode
-        this.extraInformation = extraInformation
-    }
-
-    constructor(
-            cause: Throwable,
-            extraInfo: Bundle
-    ) : this(LOAD_SDK_SDK_DEFINED_ERROR, "", cause, extraInfo)
-
-    companion object {
-        const val LOAD_SDK_SDK_DEFINED_ERROR = 102
-    }
-}
-
-5) androidx.privacysandbox.sdkruntime.core.SandboxedSdkInfo
-class SandboxedSdkInfo(
-    val name: String,
-    val version: Long
-) {
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as SandboxedSdkInfo
-        if (name != other.name) return false
-        if (version != other.version) return false
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = name.hashCode()
-        result = 31 * result + version.hashCode()
-        return result
-    }
-}
-
-6) androidx.privacysandbox.sdkruntime.core.AppOwnedSdkSandboxInterfaceCompat
-class AppOwnedSdkSandboxInterfaceCompat(
-    private val name: String,
-    private val version: Long,
-    private val binder: IBinder
-) {
-    fun getName(): String = name
-    fun getVersion(): Long = version
-    fun getInterface(): IBinder = binder
-}
-
-7) androidx.privacysandbox.sdkruntime.core.activity.ActivityHolder
-interface ActivityHolder : LifecycleOwner {
-    fun getActivity(): Activity
-    fun getOnBackPressedDispatcher(): OnBackPressedDispatcher
-}
-
-8) androidx.privacysandbox.sdkruntime.core.activity.SdkSandboxActivityHandlerCompat
-interface SdkSandboxActivityHandlerCompat {
-    fun onActivityCreated(activityHolder: ActivityHolder)
-}
-
-9) androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
-class SdkSandboxControllerCompat internal constructor(
-    private val controllerImpl: SandboxControllerImpl
-) {
-    fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-        controllerImpl.getSandboxedSdks()
-
-    fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat> =
-        controllerImpl.getAppOwnedSdkSandboxInterfaces()
-
-    fun registerSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat):
-        IBinder = controllerImpl.registerSdkSandboxActivityHandler(handlerCompat)
-
-    fun unregisterSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat) =
-        controllerImpl.unregisterSdkSandboxActivityHandler(handlerCompat)
-
-    interface SandboxControllerImpl {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat>
-        fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat>
-        fun registerSdkSandboxActivityHandler(handlerCompat: SdkSandboxActivityHandlerCompat):
-            IBinder
-        fun unregisterSdkSandboxActivityHandler(
-            handlerCompat: SdkSandboxActivityHandlerCompat
-        )
-    }
-
-    companion object {
-        private var localImpl: SandboxControllerImpl? = null
-        @JvmStatic
-        fun from(context: Context): SdkSandboxControllerCompat {
-            val clientVersion = Versions.CLIENT_VERSION
-            if (clientVersion != null) {
-                val implFromClient = localImpl
-                if (implFromClient != null) {
-                    return SdkSandboxControllerCompat(LocalImpl(implFromClient, clientVersion))
-                }
-            }
-            throw IllegalStateException("Should be loaded locally")
-        }
-        @JvmStatic
-        @Keep
-        fun injectLocalImpl(impl: SandboxControllerImpl) {
-            check(localImpl == null) { "Local implementation already injected" }
-            localImpl = impl
-        }
-    }
-}
-
-10) androidx.privacysandbox.sdkruntime.core.controller.impl.LocalImpl
-internal class LocalImpl(
-    private val implFromClient: SdkSandboxControllerCompat.SandboxControllerImpl,
-    private val clientVersion: Int
-) : SdkSandboxControllerCompat.SandboxControllerImpl {
-    override fun getSandboxedSdks(): List<SandboxedSdkCompat> {
-        return implFromClient.getSandboxedSdks()
-    }
-
-    override fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat> {
-        return if (clientVersion >= 4) {
-            implFromClient.getAppOwnedSdkSandboxInterfaces()
-        } else {
-            emptyList()
-        }
-    }
-
-    override fun registerSdkSandboxActivityHandler(
-        handlerCompat: SdkSandboxActivityHandlerCompat
-    ): IBinder {
-        if (clientVersion < 3) {
-            throw UnsupportedOperationException(
-                "Client library version doesn't support SdkActivities"
-            )
-        }
-        return implFromClient.registerSdkSandboxActivityHandler(handlerCompat)
-    }
-
-    override fun unregisterSdkSandboxActivityHandler(
-        handlerCompat: SdkSandboxActivityHandlerCompat
-    ) {
-        if (clientVersion < 3) {
-            throw UnsupportedOperationException(
-                "Client library version doesn't support SdkActivities"
-            )
-        }
-        implFromClient.unregisterSdkSandboxActivityHandler(handlerCompat)
-    }
-}
-
-11) androidx.privacysandbox.sdkruntime.test.v3.CompatProvider
-class CompatProvider : SandboxedSdkProviderCompat() {
-
-    @JvmField
-    var onLoadSdkBinder: Binder? = null
-
-    @JvmField
-    var lastOnLoadSdkParams: Bundle? = null
-
-    @JvmField
-    var isBeforeUnloadSdkCalled = false
-
-    @Throws(LoadSdkCompatException::class)
-    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
-        val result = SdkImpl(context!!)
-        onLoadSdkBinder = result
-        if (params.getBoolean("needFail", false)) {
-            throw LoadSdkCompatException(RuntimeException(), params)
-        }
-        return SandboxedSdkCompat(result)
-    }
-
-    override fun beforeUnloadSdk() {
-        isBeforeUnloadSdkCalled = true
-    }
-
-    override fun getView(
-            windowContext: Context, params: Bundle, width: Int,
-            height: Int
-    ): View {
-        return View(windowContext)
-    }
-
-    class SdkImpl(
-        private val context: Context
-    ) : Binder() {
-        fun getSandboxedSdks(): List<SandboxedSdkCompat> =
-            SdkSandboxControllerCompat.from(context).getSandboxedSdks()
-        fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat> =
-            SdkSandboxControllerCompat.from(context).getAppOwnedSdkSandboxInterfaces()
-        fun registerSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat): IBinder =
-            SdkSandboxControllerCompat.from(context).registerSdkSandboxActivityHandler(handler)
-        fun unregisterSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat) {
-            SdkSandboxControllerCompat.from(context).unregisterSdkSandboxActivityHandler(handler)
-        }
-    }
-}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkConfig.xml
deleted file mode 100644
index 83cc241..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/CompatSdkConfig.xml
+++ /dev/null
@@ -1,19 +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.
-  -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v4.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V4/classes.dex</dex-path>
-</compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/classes.dex b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/classes.dex
deleted file mode 100644
index 6b3991c..0000000
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V4/classes.dex
+++ /dev/null
Binary files differ
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.xml
similarity index 79%
rename from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
rename to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.xml
index 06853cb..8c9a6a1 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.xml
@@ -14,6 +14,6 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider</compat-entrypoint>
+    <dex-path>test-sdks/current/classes.dex</dex-path>
 </compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/currentWithResources.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/currentWithResources.xml
new file mode 100644
index 0000000..cb99939
--- /dev/null
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/currentWithResources.xml
@@ -0,0 +1,25 @@
+<!--
+  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.
+  -->
+<compat-config>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider</compat-entrypoint>
+    <dex-path>test-sdks/current/classes.dex</dex-path>
+    <dex-path>RuntimeEnabledSdks/RPackage.dex</dex-path>
+    <java-resources-root-path>RuntimeEnabledSdks/javaresources</java-resources-root-path>
+    <resource-id-remapping>
+        <r-package-class>androidx.privacysandbox.sdkruntime.test.RPackage</r-package-class>
+        <resources-package-id>42</resources-package-id>
+    </resource-id-remapping>
+</compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/InvalidEntryPointSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/invalidEntryPoint.xml
similarity index 91%
rename from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/InvalidEntryPointSdkConfig.xml
rename to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/invalidEntryPoint.xml
index d28649c..4f005d5 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/InvalidEntryPointSdkConfig.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/invalidEntryPoint.xml
@@ -15,5 +15,5 @@
   -->
 <compat-config>
     <compat-entrypoint>InvalidEntryPoint</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V1/classes.dex</dex-path>
+    <dex-path>test-sdks/current/classes.dex</dex-path>
 </compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/javaresources/test.txt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/javaresources/test.txt
similarity index 100%
rename from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V1/javaresources/test.txt
rename to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/javaresources/test.txt
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.xml
similarity index 79%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.xml
index 06853cb..2bf4d37 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.xml
@@ -14,6 +14,6 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.v1.CompatProvider</compat-entrypoint>
+    <dex-path>test-sdks/v1/classes.dex</dex-path>
 </compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v2.xml
similarity index 79%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v2.xml
index 06853cb..ed4f3707 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v2.xml
@@ -14,6 +14,6 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.v2.CompatProvider</compat-entrypoint>
+    <dex-path>test-sdks/v2/classes.dex</dex-path>
 </compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v4.xml
similarity index 79%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v4.xml
index 06853cb..ea3c856 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v4.xml
@@ -14,6 +14,6 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.v4.CompatProvider</compat-entrypoint>
+    <dex-path>test-sdks/v4/classes.dex</dex-path>
 </compat-config>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
index 30d5c07..edea99d 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerAppOwnedInterfacesTest.kt
@@ -139,7 +139,7 @@
     fun sdkController_getAppOwnedSdkSandboxInterfaces_returnsRegisteredAppOwnedInterfaces() {
         val localSdk = runBlocking {
             sandboxManagerCompat.loadSdk(
-                "androidx.privacysandbox.sdkruntime.test.v4",
+                TestSdkConfigs.forSdkName("v4").packageName,
                 Bundle()
             )
         }
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatSandboxedTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatSandboxedTest.kt
index af839829..2f31e22 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatSandboxedTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatSandboxedTest.kt
@@ -242,7 +242,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(mContext)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.CURRENT.packageName,
+                Bundle()
+            )
         }
 
         val sandboxedSdks = managerCompat.getSandboxedSdks()
@@ -263,7 +266,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(mContext)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.CURRENT.packageName,
+                Bundle()
+            )
         }
 
         val result = managerCompat.getSandboxedSdks().map { it.getInterface() }
@@ -285,7 +291,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(mContext)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v2", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.forSdkName("v2").packageName,
+                Bundle()
+            )
         }
 
         val testSdk = localSdk.asTestSdk()
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatTest.kt
index faec8df..a7e1317 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/SdkSandboxManagerCompatTest.kt
@@ -121,7 +121,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
         val result = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.CURRENT.packageName,
+                Bundle()
+            )
         }
 
         assertThat(result.getInterface()!!.javaClass.classLoader)
@@ -130,7 +133,7 @@
         assertThat(result.getSdkInfo())
             .isEqualTo(
                 SandboxedSdkInfo(
-                    name = "androidx.privacysandbox.sdkruntime.test.v1",
+                    name = TestSdkConfigs.CURRENT.packageName,
                     version = 42
                 )
             )
@@ -146,7 +149,10 @@
 
         val result = assertThrows(LoadSdkCompatException::class.java) {
             runBlocking {
-                managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", params)
+                managerCompat.loadSdk(
+                    TestSdkConfigs.CURRENT.packageName,
+                    params
+                )
             }
         }
 
@@ -162,8 +168,8 @@
         val result = assertThrows(LoadSdkCompatException::class.java) {
             runBlocking {
                 managerCompat.loadSdk(
-                    sdkName = "androidx.privacysandbox.sdkruntime.test.invalidEntryPoint",
-                    params = Bundle()
+                    TestSdkConfigs.forSdkName("invalidEntryPoint").packageName,
+                    Bundle()
                 )
             }
         }
@@ -177,7 +183,7 @@
         val context = ApplicationProvider.getApplicationContext<Context>()
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
-        val sdkName = "androidx.privacysandbox.sdkruntime.test.v1"
+        val sdkName = TestSdkConfigs.CURRENT.packageName
 
         val sdkToUnload = runBlocking {
             managerCompat.loadSdk(sdkName, Bundle())
@@ -215,7 +221,7 @@
         val context = ApplicationProvider.getApplicationContext<Context>()
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
-        val sdkName = "androidx.privacysandbox.sdkruntime.test.v1"
+        val sdkName = TestSdkConfigs.CURRENT.packageName
 
         runBlocking {
             managerCompat.loadSdk(sdkName, Bundle())
@@ -310,7 +316,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v3", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.forSdkName("v4").packageName,
+                Bundle()
+            )
         }
 
         val handler = CatchingSdkActivityHandler()
@@ -334,11 +343,17 @@
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v2", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.forSdkName("v2").packageName,
+                Bundle()
+            )
         }
 
         val anotherLocalSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.CURRENT.packageName,
+                Bundle()
+            )
         }
 
         val testSdk = localSdk.asTestSdk()
@@ -361,7 +376,10 @@
         val managerCompat = SdkSandboxManagerCompat.from(context)
 
         val localSdk = runBlocking {
-            managerCompat.loadSdk("androidx.privacysandbox.sdkruntime.test.v1", Bundle())
+            managerCompat.loadSdk(
+                TestSdkConfigs.CURRENT.packageName,
+                Bundle()
+            )
         }
 
         val sandboxedSdks = managerCompat.getSandboxedSdks()
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/TestSdkConfigs.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/TestSdkConfigs.kt
new file mode 100644
index 0000000..9fb1eb1
--- /dev/null
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/TestSdkConfigs.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.sdkruntime.client
+
+import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
+import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfigsHolder
+import androidx.test.core.app.ApplicationProvider
+import java.io.FileNotFoundException
+
+/**
+ * Holds information about all TestSDKs.
+ */
+internal object TestSdkConfigs {
+
+    private val ALL_CONFIGS: LocalSdkConfigsHolder by lazy {
+        LocalSdkConfigsHolder.load(ApplicationProvider.getApplicationContext())
+    }
+
+    /**
+     * Minimal TestSDK which built with HEAD version of sdkruntime-core library.
+     */
+    val CURRENT: LocalSdkConfig by lazy {
+        forSdkName("current")
+    }
+
+    /**
+     * Same as [CURRENT] but also has optional fields set, such as:
+     * 1) [LocalSdkConfig.javaResourcesRoot]
+     * 2) [LocalSdkConfig.resourceRemapping]
+     */
+    val CURRENT_WITH_RESOURCES: LocalSdkConfig by lazy {
+        forSdkName("currentWithResources")
+    }
+
+    /**
+     * Return LocalSdkConfig for TestSDK.
+     * TestSDK should be registered in RuntimeEnabledSdkTable.xml with package name:
+     * "androidx.privacysandbox.sdkruntime.testsdk.[testSdkName]"
+     */
+    fun forSdkName(testSdkName: String): LocalSdkConfig {
+        val sdkPackageName = "androidx.privacysandbox.sdkruntime.testsdk.$testSdkName"
+        return ALL_CONFIGS
+            .getSdkConfig(sdkPackageName)
+            ?: throw FileNotFoundException("Can't find LocalSdkConfig for $sdkPackageName")
+    }
+}
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/config/LocalSdkConfigsHolderTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/config/LocalSdkConfigsHolderTest.kt
index 1f0ccf4..e054456 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/config/LocalSdkConfigsHolderTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/config/LocalSdkConfigsHolderTest.kt
@@ -15,6 +15,7 @@
  */
 package androidx.privacysandbox.sdkruntime.client.config
 
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
@@ -43,19 +44,17 @@
         )
 
         val result = configHolder.getSdkConfig(
-            "androidx.privacysandbox.sdkruntime.test.v1"
+            TestSdkConfigs.CURRENT.packageName
         )
 
-        assertThat(result)
-            .isEqualTo(
-                LocalSdkConfig(
-                    packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-                    versionMajor = 42,
-                    dexPaths = listOf("RuntimeEnabledSdks/V1/classes.dex"),
-                    entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-                    javaResourcesRoot = "RuntimeEnabledSdks/V1/javaresources"
-                )
-            )
+        val expectedConfig = LocalSdkConfig(
+            packageName = "androidx.privacysandbox.sdkruntime.testsdk.current",
+            versionMajor = 42,
+            dexPaths = listOf("test-sdks/current/classes.dex"),
+            entryPoint = "androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider",
+        )
+
+        assertThat(result).isEqualTo(expectedConfig)
     }
 
     @Test
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/FileClassLoaderFactoryTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/FileClassLoaderFactoryTest.kt
index 32d2773a..05470dd 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/FileClassLoaderFactoryTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/FileClassLoaderFactoryTest.kt
@@ -17,6 +17,7 @@
 package androidx.privacysandbox.sdkruntime.client.loader
 
 import android.content.Context
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
 import androidx.privacysandbox.sdkruntime.client.loader.storage.LocalSdkDexFiles
 import androidx.privacysandbox.sdkruntime.client.loader.storage.LocalSdkStorage
@@ -38,13 +39,7 @@
 
     @Before
     fun setUp() {
-        testSdkConfig = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf(
-                "RuntimeEnabledSdks/V1/classes.dex",
-            ),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-        )
+        testSdkConfig = TestSdkConfigs.CURRENT
     }
 
     @Test
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/InMemorySdkClassLoaderFactoryTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/InMemorySdkClassLoaderFactoryTest.kt
index c4d0ef1..275eda1 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/InMemorySdkClassLoaderFactoryTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/InMemorySdkClassLoaderFactoryTest.kt
@@ -16,6 +16,7 @@
 package androidx.privacysandbox.sdkruntime.client.loader
 
 import android.os.Build
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
 import androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
 import androidx.test.core.app.ApplicationProvider
@@ -41,21 +42,11 @@
         factoryUnderTest = InMemorySdkClassLoaderFactory.create(
             ApplicationProvider.getApplicationContext()
         )
-        singleDexSdkInfo = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf("RuntimeEnabledSdks/V1/classes.dex"),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-            javaResourcesRoot = "RuntimeEnabledSdks/V1/"
-        )
-        multipleDexSdkInfo = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf(
-                "RuntimeEnabledSdks/V1/classes.dex",
-                "RuntimeEnabledSdks/RPackage.dex",
-            ),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-            javaResourcesRoot = "RuntimeEnabledSdks/V1/"
-        )
+        singleDexSdkInfo = TestSdkConfigs.CURRENT
+        assertThat(singleDexSdkInfo.dexPaths.size).isEqualTo(1)
+
+        multipleDexSdkInfo = TestSdkConfigs.CURRENT_WITH_RESOURCES
+        assertThat(multipleDexSdkInfo.dexPaths.size).isEqualTo(2)
     }
 
     @Test
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/JavaResourcesLoadingClassLoaderFactoryTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/JavaResourcesLoadingClassLoaderFactoryTest.kt
index b426788..530c76a1 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/JavaResourcesLoadingClassLoaderFactoryTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/JavaResourcesLoadingClassLoaderFactoryTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.privacysandbox.sdkruntime.client.loader
 
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
@@ -42,12 +43,7 @@
                     parent
             }
         )
-        testSdkConfig = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf("RuntimeEnabledSdks/V1/classes.dex"),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-            javaResourcesRoot = "RuntimeEnabledSdks/V1/javaresources"
-        )
+        testSdkConfig = TestSdkConfigs.CURRENT_WITH_RESOURCES
     }
 
     @Test
@@ -59,7 +55,7 @@
         val resource = classLoader.getResource("test.txt")
 
         val appResource = appClassloader.getResource(
-            "assets/RuntimeEnabledSdks/V1/javaresources/test.txt"
+            "assets/RuntimeEnabledSdks/javaresources/test.txt"
         )
         assertThat(resource).isNotNull()
         assertThat(resource).isEqualTo(appResource)
@@ -92,7 +88,7 @@
         assertThat(resources.isEmpty()).isFalse()
 
         val appResources = appClassloader
-            .getResources("assets/RuntimeEnabledSdks/V1/javaresources/test.txt")
+            .getResources("assets/RuntimeEnabledSdks/javaresources/test.txt")
             .toList()
 
         assertThat(appResources).isEqualTo(resources)
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/LocalSdkProviderTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/LocalSdkProviderTest.kt
index 09f256a..646f891 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/LocalSdkProviderTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/LocalSdkProviderTest.kt
@@ -22,6 +22,7 @@
 import androidx.lifecycle.Lifecycle
 import androidx.privacysandbox.sdkruntime.client.EmptyActivity
 import androidx.privacysandbox.sdkruntime.client.TestActivityHolder
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
 import androidx.privacysandbox.sdkruntime.client.loader.impl.SandboxedSdkContextCompat
 import androidx.privacysandbox.sdkruntime.client.loader.storage.TestLocalSdkStorage
@@ -42,6 +43,7 @@
 import java.io.File
 import org.junit.Assert.assertThrows
 import org.junit.Assume.assumeTrue
+import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
@@ -49,12 +51,23 @@
 @SmallTest
 @RunWith(Parameterized::class)
 internal class LocalSdkProviderTest(
-    @Suppress("unused") private val sdkPath: String,
-    private val sdkVersion: Int,
-    private val controller: TestStubController,
-    private val loadedSdk: LocalSdkProvider
+    private val sdkName: String,
+    private val sdkVersion: Int
 ) {
 
+    private lateinit var controller: TestStubController
+    private lateinit var loadedSdk: LocalSdkProvider
+
+    @Before
+    fun setUp() {
+        val sdkConfig = TestSdkConfigs.forSdkName(sdkName)
+
+        controller = TestStubController()
+        loadedSdk = loadTestSdkFromAssets(sdkConfig, controller)
+        assertThat(loadedSdk.extractApiVersion())
+            .isEqualTo(sdkVersion)
+    }
+
     @Test
     fun loadSdk_attachCorrectContext() {
         val sdkContext = loadedSdk.extractSdkContext()
@@ -248,83 +261,33 @@
         }
     }
 
-    internal class TestSdkInfo internal constructor(
-        val apiVersion: Int,
-        dexPath: String,
-        sdkProviderClass: String
-    ) {
-        val localSdkConfig = LocalSdkConfig(
-            packageName = "test.$apiVersion.$sdkProviderClass",
-            dexPaths = listOf(dexPath),
-            entryPoint = sdkProviderClass
-        )
-    }
-
     companion object {
-        private val SDKS = arrayOf(
-            TestSdkInfo(
-                1,
-                "RuntimeEnabledSdks/V1/classes.dex",
-                "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider"
-            ),
-            TestSdkInfo(
-                2,
-                "RuntimeEnabledSdks/V2/classes.dex",
-                "androidx.privacysandbox.sdkruntime.test.v2.CompatProvider"
-            ),
-            TestSdkInfo(
-                3,
-                "RuntimeEnabledSdks/V3/classes.dex",
-                "androidx.privacysandbox.sdkruntime.test.v3.CompatProvider"
-            ),
-            TestSdkInfo(
-                4,
-                "RuntimeEnabledSdks/V4/classes.dex",
-                "androidx.privacysandbox.sdkruntime.test.v4.CompatProvider"
-            )
-        )
 
+        /**
+         * Create test params for each previously released [Versions.API_VERSION] + current one.
+         * Each released version must have test-sdk named as "vX" (where X is version to test).
+         * These TestSDKs should be registered in RuntimeEnabledSdkTable.xml and be compatible with
+         * [TestSdkWrapper].
+         */
         @Parameterized.Parameters(name = "sdk: {0}, version: {1}")
         @JvmStatic
         fun params(): List<Array<Any>> = buildList {
-            assertThat(SDKS.size).isEqualTo(Versions.API_VERSION)
-
-            for (i in SDKS.indices) {
-                val sdk = SDKS[i]
-                assertThat(sdk.apiVersion).isEqualTo(i + 1)
-
-                val controller = TestStubController()
-                val loadedSdk = loadTestSdkFromAssets(sdk.localSdkConfig, controller)
-                assertThat(loadedSdk.extractApiVersion())
-                    .isEqualTo(sdk.apiVersion)
-
+            for (apiVersion in 1..Versions.API_VERSION) {
+                if (apiVersion == 3) {
+                    continue // V3 was released as V4 (original release postponed)
+                }
                 add(
                     arrayOf(
-                        sdk.localSdkConfig.dexPaths[0],
-                        sdk.apiVersion,
-                        controller,
-                        loadedSdk
+                        "v$apiVersion",
+                        apiVersion,
                     )
                 )
             }
 
-            val currentVersionSdk = TestSdkInfo(
-                Versions.API_VERSION,
-                "test-sdks/current/classes.dex",
-                "androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider"
-            )
-            val controller = TestStubController()
-
-            val loadedSdk = loadTestSdkFromAssets(currentVersionSdk.localSdkConfig, controller)
-            assertThat(loadedSdk.extractApiVersion())
-                .isEqualTo(currentVersionSdk.apiVersion)
-
             add(
                 arrayOf(
-                    currentVersionSdk.localSdkConfig.dexPaths[0],
-                    currentVersionSdk.apiVersion,
-                    controller,
-                    loadedSdk
+                    "current",
+                    Versions.API_VERSION
                 )
             )
         }
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SandboxedSdkContextCompatTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SandboxedSdkContextCompatTest.kt
index 8a318c7..d454082 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SandboxedSdkContextCompatTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SandboxedSdkContextCompatTest.kt
@@ -449,7 +449,7 @@
     companion object {
         private const val SDK_ROOT_FOLDER = "RuntimeEnabledSdksData"
         private const val SDK_SHARED_PREFERENCES_PREFIX = "RuntimeEnabledSdk"
-        private const val SDK_PACKAGE_NAME = "androidx.privacysandbox.sdkruntime.testsdk1"
+        private const val SDK_PACKAGE_NAME = "androidx.privacysandbox.sdkruntime.storageContextTest"
 
         @Parameterized.Parameters(name = "{0}")
         @JvmStatic
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SdkLoaderTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SdkLoaderTest.kt
index 09812407..e693b00 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SdkLoaderTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/SdkLoaderTest.kt
@@ -18,8 +18,8 @@
 import android.content.Context
 import android.os.Build
 import android.os.IBinder
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
-import androidx.privacysandbox.sdkruntime.client.config.ResourceRemappingConfig
 import androidx.privacysandbox.sdkruntime.core.AppOwnedSdkSandboxInterfaceCompat
 import androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
 import androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
@@ -52,19 +52,7 @@
             context = context,
             controller = NoOpImpl(),
         )
-        testSdkConfig = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf(
-                "RuntimeEnabledSdks/V1/classes.dex",
-                "RuntimeEnabledSdks/RPackage.dex"
-            ),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-            javaResourcesRoot = "RuntimeEnabledSdks/V1/javaresources",
-            resourceRemapping = ResourceRemappingConfig(
-                rPackageClassName = "androidx.privacysandbox.sdkruntime.test.RPackage",
-                packageId = 42
-            )
-        )
+        testSdkConfig = TestSdkConfigs.CURRENT_WITH_RESOURCES
 
         // Clean extracted SDKs between tests
         val codeCacheDir = File(context.applicationInfo.dataDir, "code_cache")
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/storage/CachedLocalSdkStorageTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/storage/CachedLocalSdkStorageTest.kt
index 3e5ba24..900ac0a 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/storage/CachedLocalSdkStorageTest.kt
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/loader/storage/CachedLocalSdkStorageTest.kt
@@ -19,6 +19,7 @@
 import android.content.Context
 import android.os.Environment
 import android.os.StatFs
+import androidx.privacysandbox.sdkruntime.client.TestSdkConfigs
 import androidx.privacysandbox.sdkruntime.client.config.LocalSdkConfig
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -53,14 +54,8 @@
             lowSpaceThreshold = disabledLowSpaceModeThreshold()
         )
 
-        testSdkConfig = LocalSdkConfig(
-            packageName = "androidx.privacysandbox.sdkruntime.test.v1",
-            dexPaths = listOf(
-                "RuntimeEnabledSdks/V1/classes.dex",
-                "RuntimeEnabledSdks/RPackage.dex"
-            ),
-            entryPoint = "androidx.privacysandbox.sdkruntime.test.v1.CompatProvider",
-        )
+        testSdkConfig = TestSdkConfigs.CURRENT_WITH_RESOURCES
+        assertThat(testSdkConfig.dexPaths.size).isEqualTo(2)
 
         sdkFolder = LocalSdkFolderProvider
             .create(context)
diff --git a/privacysandbox/sdkruntime/test-sdks/v1/build.gradle b/privacysandbox/sdkruntime/test-sdks/v1/build.gradle
new file mode 100644
index 0000000..ecd8b9c
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v1/build.gradle
@@ -0,0 +1,56 @@
+/*
+ * 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 com.android.build.api.artifact.SingleArtifact
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace "androidx.privacysandbox.sdkruntime.testsdk.v1"
+}
+
+dependencies {
+    implementation("androidx.privacysandbox.sdkruntime:sdkruntime-core:1.0.0-alpha01")
+}
+
+/*
+ * Allow integration tests to consume the APK produced by this project
+ */
+configurations {
+    testSdkApk {
+        canBeConsumed = true
+        canBeResolved = false
+        attributes {
+            attribute(
+                    LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
+                    objects.named(LibraryElements, "testSdkApk")
+            )
+        }
+    }
+}
+
+androidComponents {
+    beforeVariants(selector().all()) { enabled = buildType == 'release' }
+    onVariants(selector().all().withBuildType("release"), { variant ->
+        artifacts {
+            testSdkApk(variant.artifacts.get(SingleArtifact.APK.INSTANCE))
+        }
+    })
+}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/test-sdks/v1/src/main/AndroidManifest.xml
similarity index 75%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/test-sdks/v1/src/main/AndroidManifest.xml
index 06853cb..8de5974 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/test-sdks/v1/src/main/AndroidManifest.xml
@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
 <!--
   Copyright 2023 The Android Open Source Project
 
@@ -13,7 +14,5 @@
   See the License for the specific language governing permissions and
   limitations under the License.
   -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
-</compat-config>
\ No newline at end of file
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+</manifest>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/test-sdks/v1/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v1/CompatProvider.kt b/privacysandbox/sdkruntime/test-sdks/v1/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v1/CompatProvider.kt
new file mode 100644
index 0000000..c2b5312
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v1/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v1/CompatProvider.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.sdkruntime.testsdk.v1
+
+import android.content.Context
+import android.os.Binder
+import android.os.Bundle
+import android.view.View
+import androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
+
+@Suppress("unused") // Reflection usage from tests in privacysandbox:sdkruntime:sdkruntime-client
+class CompatProvider : SandboxedSdkProviderCompat() {
+    @JvmField
+    var onLoadSdkBinder: Binder? = null
+
+    @JvmField
+    var lastOnLoadSdkParams: Bundle? = null
+
+    @JvmField
+    var isBeforeUnloadSdkCalled = false
+
+    @Throws(LoadSdkCompatException::class)
+    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
+        val result = Binder()
+        onLoadSdkBinder = result
+
+        lastOnLoadSdkParams = params
+        if (params.getBoolean("needFail", false)) {
+            throw LoadSdkCompatException(RuntimeException(), params)
+        }
+        return SandboxedSdkCompat(result)
+    }
+
+    override fun beforeUnloadSdk() {
+        isBeforeUnloadSdkCalled = true
+    }
+
+    override fun getView(
+        windowContext: Context,
+        params: Bundle,
+        width: Int,
+        height: Int
+    ): View {
+        return View(windowContext)
+    }
+}
diff --git a/privacysandbox/sdkruntime/test-sdks/v2/build.gradle b/privacysandbox/sdkruntime/test-sdks/v2/build.gradle
new file mode 100644
index 0000000..3c2243a
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v2/build.gradle
@@ -0,0 +1,56 @@
+/*
+ * 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 com.android.build.api.artifact.SingleArtifact
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace "androidx.privacysandbox.sdkruntime.testsdk.v2"
+}
+
+dependencies {
+    implementation("androidx.privacysandbox.sdkruntime:sdkruntime-core:1.0.0-alpha02")
+}
+
+/*
+ * Allow integration tests to consume the APK produced by this project
+ */
+configurations {
+    testSdkApk {
+        canBeConsumed = true
+        canBeResolved = false
+        attributes {
+            attribute(
+                    LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
+                    objects.named(LibraryElements, "testSdkApk")
+            )
+        }
+    }
+}
+
+androidComponents {
+    beforeVariants(selector().all()) { enabled = buildType == 'release' }
+    onVariants(selector().all().withBuildType("release"), { variant ->
+        artifacts {
+            testSdkApk(variant.artifacts.get(SingleArtifact.APK.INSTANCE))
+        }
+    })
+}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/test-sdks/v2/src/main/AndroidManifest.xml
similarity index 75%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/test-sdks/v2/src/main/AndroidManifest.xml
index 06853cb..8de5974 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/test-sdks/v2/src/main/AndroidManifest.xml
@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
 <!--
   Copyright 2023 The Android Open Source Project
 
@@ -13,7 +14,5 @@
   See the License for the specific language governing permissions and
   limitations under the License.
   -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
-</compat-config>
\ No newline at end of file
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+</manifest>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/test-sdks/v2/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v2/CompatProvider.kt b/privacysandbox/sdkruntime/test-sdks/v2/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v2/CompatProvider.kt
new file mode 100644
index 0000000..cf4d8c7
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v2/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v2/CompatProvider.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.sdkruntime.testsdk.v2
+
+import android.content.Context
+import android.os.Binder
+import android.os.Bundle
+import android.view.View
+import androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
+import androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
+
+@Suppress("unused") // Reflection usage from tests in privacysandbox:sdkruntime:sdkruntime-client
+class CompatProvider : SandboxedSdkProviderCompat() {
+    @JvmField
+    var onLoadSdkBinder: Binder? = null
+
+    @JvmField
+    var lastOnLoadSdkParams: Bundle? = null
+
+    @JvmField
+    var isBeforeUnloadSdkCalled = false
+
+    @Throws(LoadSdkCompatException::class)
+    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
+        val result = SdkImpl(context!!)
+        onLoadSdkBinder = result
+
+        lastOnLoadSdkParams = params
+        if (params.getBoolean("needFail", false)) {
+            throw LoadSdkCompatException(RuntimeException(), params)
+        }
+        return SandboxedSdkCompat(result)
+    }
+
+    override fun beforeUnloadSdk() {
+        isBeforeUnloadSdkCalled = true
+    }
+
+    override fun getView(
+        windowContext: Context,
+        params: Bundle,
+        width: Int,
+        height: Int
+    ): View {
+        return View(windowContext)
+    }
+
+    internal class SdkImpl(
+        private val context: Context
+    ) : Binder() {
+        fun getSandboxedSdks(): List<SandboxedSdkCompat> =
+            SdkSandboxControllerCompat.from(context).getSandboxedSdks()
+    }
+}
diff --git a/privacysandbox/sdkruntime/test-sdks/v4/build.gradle b/privacysandbox/sdkruntime/test-sdks/v4/build.gradle
new file mode 100644
index 0000000..901ebd5
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v4/build.gradle
@@ -0,0 +1,61 @@
+/*
+ * 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 com.android.build.api.artifact.SingleArtifact
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace "androidx.privacysandbox.sdkruntime.testsdk.v4"
+}
+
+dependencies {
+    implementation("androidx.privacysandbox.sdkruntime:sdkruntime-core:1.0.0-alpha05") {
+        exclude(group: "androidx.core")
+    }
+    implementation("androidx.core:core:1.12.0-alpha05") {
+        because("Used by sdkruntime-core. Original dependency require preview SDK.")
+    }
+}
+
+/*
+ * Allow integration tests to consume the APK produced by this project
+ */
+configurations {
+    testSdkApk {
+        canBeConsumed = true
+        canBeResolved = false
+        attributes {
+            attribute(
+                    LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
+                    objects.named(LibraryElements, "testSdkApk")
+            )
+        }
+    }
+}
+
+androidComponents {
+    beforeVariants(selector().all()) { enabled = buildType == 'release' }
+    onVariants(selector().all().withBuildType("release"), { variant ->
+        artifacts {
+            testSdkApk(variant.artifacts.get(SingleArtifact.APK.INSTANCE))
+        }
+    })
+}
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml b/privacysandbox/sdkruntime/test-sdks/v4/src/main/AndroidManifest.xml
similarity index 75%
copy from privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
copy to privacysandbox/sdkruntime/test-sdks/v4/src/main/AndroidManifest.xml
index 06853cb..8de5974 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/V2/CompatSdkConfig.xml
+++ b/privacysandbox/sdkruntime/test-sdks/v4/src/main/AndroidManifest.xml
@@ -1,3 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
 <!--
   Copyright 2023 The Android Open Source Project
 
@@ -13,7 +14,5 @@
   See the License for the specific language governing permissions and
   limitations under the License.
   -->
-<compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.test.v2.CompatProvider</compat-entrypoint>
-    <dex-path>RuntimeEnabledSdks/V2/classes.dex</dex-path>
-</compat-config>
\ No newline at end of file
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+</manifest>
\ No newline at end of file
diff --git a/privacysandbox/sdkruntime/test-sdks/v4/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v4/CompatProvider.kt b/privacysandbox/sdkruntime/test-sdks/v4/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v4/CompatProvider.kt
new file mode 100644
index 0000000..a97aec2
--- /dev/null
+++ b/privacysandbox/sdkruntime/test-sdks/v4/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v4/CompatProvider.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.sdkruntime.testsdk.v4
+
+import android.content.Context
+import android.os.Binder
+import android.os.Bundle
+import android.os.IBinder
+import android.view.View
+import androidx.privacysandbox.sdkruntime.core.AppOwnedSdkSandboxInterfaceCompat
+import androidx.privacysandbox.sdkruntime.core.LoadSdkCompatException
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkCompat
+import androidx.privacysandbox.sdkruntime.core.SandboxedSdkProviderCompat
+import androidx.privacysandbox.sdkruntime.core.activity.SdkSandboxActivityHandlerCompat
+import androidx.privacysandbox.sdkruntime.core.controller.SdkSandboxControllerCompat
+
+@Suppress("unused") // Reflection usage from tests in privacysandbox:sdkruntime:sdkruntime-client
+class CompatProvider : SandboxedSdkProviderCompat() {
+    @JvmField
+    var onLoadSdkBinder: Binder? = null
+
+    @JvmField
+    var lastOnLoadSdkParams: Bundle? = null
+
+    @JvmField
+    var isBeforeUnloadSdkCalled = false
+
+    @Throws(LoadSdkCompatException::class)
+    override fun onLoadSdk(params: Bundle): SandboxedSdkCompat {
+        val result = SdkImpl(context!!)
+        onLoadSdkBinder = result
+
+        lastOnLoadSdkParams = params
+        if (params.getBoolean("needFail", false)) {
+            throw LoadSdkCompatException(RuntimeException(), params)
+        }
+        return SandboxedSdkCompat(result)
+    }
+
+    override fun beforeUnloadSdk() {
+        isBeforeUnloadSdkCalled = true
+    }
+
+    override fun getView(
+        windowContext: Context,
+        params: Bundle,
+        width: Int,
+        height: Int
+    ): View {
+        return View(windowContext)
+    }
+
+    internal class SdkImpl(
+        private val context: Context
+    ) : Binder() {
+        fun getSandboxedSdks(): List<SandboxedSdkCompat> =
+            SdkSandboxControllerCompat.from(context).getSandboxedSdks()
+
+        fun getAppOwnedSdkSandboxInterfaces(): List<AppOwnedSdkSandboxInterfaceCompat> =
+            SdkSandboxControllerCompat.from(context).getAppOwnedSdkSandboxInterfaces()
+
+        fun registerSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat): IBinder =
+            SdkSandboxControllerCompat.from(context).registerSdkSandboxActivityHandler(handler)
+
+        fun unregisterSdkSandboxActivityHandler(handler: SdkSandboxActivityHandlerCompat) {
+            SdkSandboxControllerCompat.from(context).unregisterSdkSandboxActivityHandler(handler)
+        }
+    }
+}
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 19cb68b..ee38558 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
@@ -352,6 +352,16 @@
     }
 
     @Test
+    fun onConfigurationChangedTestSameConfiguration() {
+        addViewToLayout()
+        assertThat(openSessionLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+        activity.runOnUiThread {
+            activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+        }
+        assertThat(configChangedLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isFalse()
+    }
+
+    @Test
     fun onSizeChangedTest() {
         addViewToLayout()
         assertThat(openSessionLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
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 b3d581a..71dbb5c 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
@@ -322,9 +322,10 @@
         checkClientOpenSession()
     }
 
-    // TODO(b/270971893) Compare to old configuration before notifying of configuration change.
     override fun onConfigurationChanged(config: Configuration?) {
         requireNotNull(config) { "Config cannot be null" }
+        if (context.resources.configuration == config)
+            return
         super.onConfigurationChanged(config)
         client?.notifyConfigurationChanged(config)
         checkClientOpenSession()
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/BooksDao.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/BooksDao.kt
index 29f861c7..f780922 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/BooksDao.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/BooksDao.kt
@@ -53,7 +53,6 @@
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.runBlocking
 
-@JvmDefaultWithCompatibility
 @Dao
 @TypeConverters(DateConverter::class, AnswerConverter::class)
 interface BooksDao {
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/DerivedDao.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/DerivedDao.kt
index b50dbce..8e5ba0c 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/DerivedDao.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/DerivedDao.kt
@@ -21,7 +21,6 @@
 import androidx.room.Transaction
 import androidx.room.integration.kotlintestapp.vo.Author
 
-@JvmDefaultWithCompatibility
 @Dao
 interface DerivedDao : BaseDao<Author> {
 
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/MusicDao.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/MusicDao.kt
index 426a4f9..a61dbbd 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/MusicDao.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/MusicDao.kt
@@ -51,7 +51,6 @@
 import java.util.Date
 import kotlinx.coroutines.flow.Flow
 
-@JvmDefaultWithCompatibility
 @Dao
 interface MusicDao {
     @Insert
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/PetDao.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/PetDao.kt
index 3d091dd..cd7125b 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/PetDao.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/PetDao.kt
@@ -30,7 +30,6 @@
 import com.google.common.util.concurrent.ListenableFuture
 import io.reactivex.Flowable
 
-@JvmDefaultWithCompatibility
 @Dao
 interface PetDao {
     @Insert(onConflict = OnConflictStrategy.REPLACE)
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/ToyDao.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/ToyDao.kt
index d67ea92..fce9513 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/ToyDao.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/dao/ToyDao.kt
@@ -22,7 +22,6 @@
 import androidx.room.Update
 import androidx.room.integration.kotlintestapp.vo.Toy
 
-@JvmDefaultWithCompatibility
 @Dao
 interface ToyDao {
     @Insert
diff --git a/room/room-compiler-processing-testing/src/main/java/androidx/room/compiler/processing/util/compiler/TestKspRegistrar.kt b/room/room-compiler-processing-testing/src/main/java/androidx/room/compiler/processing/util/compiler/TestKspRegistrar.kt
index 9ab12a4..60857f8 100644
--- a/room/room-compiler-processing-testing/src/main/java/androidx/room/compiler/processing/util/compiler/TestKspRegistrar.kt
+++ b/room/room-compiler-processing-testing/src/main/java/androidx/room/compiler/processing/util/compiler/TestKspRegistrar.kt
@@ -33,6 +33,7 @@
 import org.jetbrains.kotlin.com.intellij.psi.PsiTreeChangeListener
 import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
 import org.jetbrains.kotlin.config.CompilerConfiguration
+import org.jetbrains.kotlin.config.languageVersionSettings
 import org.jetbrains.kotlin.resolve.extensions.AnalysisHandlerExtension
 
 /**
@@ -52,11 +53,10 @@
         configuration: CompilerConfiguration
     ) {
         baseOptions.apply {
-            projectBaseDir = project.basePath?.let {
-                File(it)
-            } ?: kspWorkingDir
+            projectBaseDir = project.basePath?.let { File(it) } ?: kspWorkingDir
             incremental = false
             incrementalLog = false
+            languageVersionSettings = configuration.languageVersionSettings
             // NOT supported yet, hence we set a default
             classOutputDir = classOutputDir ?: kspWorkingDir.resolve(
                 KspCliOption
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XAnnotated.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XAnnotated.kt
index f72cec5..ec4ca7f 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XAnnotated.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XAnnotated.kt
@@ -137,6 +137,12 @@
     fun hasAnyAnnotation(annotations: Collection<ClassName>) = annotations.any(this::hasAnnotation)
 
     /**
+     * Returns `true` if this element has one of the [annotations].
+     */
+    fun hasAnyAnnotation(vararg annotations: XClassName) =
+        annotations.any(this::hasAnnotation)
+
+    /**
      * Returns `true` if this element has all the [annotations].
      */
     fun hasAllAnnotations(vararg annotations: ClassName): Boolean =
@@ -154,6 +160,12 @@
     fun hasAllAnnotations(annotations: Collection<ClassName>): Boolean =
         annotations.all(this::hasAnnotation)
 
+    /**
+     * Returns `true` if this element has all the [annotations].
+     */
+    fun hasAllAnnotations(vararg annotations: XClassName): Boolean =
+        annotations.all(this::hasAnnotation)
+
     @Deprecated(
         replaceWith = ReplaceWith("getAnnotation(annotation)"),
         message = "Use getAnnotation(not repeatable) or getAnnotations (repeatable)"
@@ -231,6 +243,17 @@
     }
 
     /**
+     * Returns the [XAnnotation] that has the same qualified name as [annotationName].
+     *
+     * @see [hasAnnotation]
+     * @see [getAnnotations]
+     * @see [hasAnnotationWithPackage]
+     */
+    fun requireAnnotation(annotationName: XClassName): XAnnotation {
+        return getAnnotation(annotationName)!!
+    }
+
+    /**
      * Returns a boxed instance of the given [annotation] class where fields can be read.
      *
      * @see [hasAnnotation]
@@ -242,3 +265,15 @@
             "Cannot find required annotation $annotation"
         }
 }
+
+/**
+ * Returns `true` if this element has one of the [annotations].
+ */
+fun XAnnotated.hasAnyAnnotation(annotations: Collection<XClassName>) =
+    annotations.any(this::hasAnnotation)
+
+/**
+ * Returns `true` if this element has all the [annotations].
+ */
+fun XAnnotated.hasAllAnnotations(annotations: Collection<XClassName>): Boolean =
+    annotations.all(this::hasAnnotation)
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspAnnotated.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspAnnotated.kt
index 994ab5f..b5ba4c8 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspAnnotated.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspAnnotated.kt
@@ -19,7 +19,6 @@
 import androidx.room.compiler.processing.InternalXAnnotated
 import androidx.room.compiler.processing.XAnnotation
 import androidx.room.compiler.processing.XAnnotationBox
-import androidx.room.compiler.processing.ksp.KspAnnotated.UseSiteFilter.Companion.getDeclaredTargets
 import androidx.room.compiler.processing.unwrapRepeatedAnnotationsFromContainer
 import com.google.devtools.ksp.symbol.AnnotationUseSiteTarget
 import com.google.devtools.ksp.symbol.KSAnnotated
@@ -163,6 +162,10 @@
                 acceptedSiteTarget = AnnotationUseSiteTarget.SETPARAM,
                 acceptedTargets = setOf(AnnotationTarget.PROPERTY_SETTER)
             )
+            val NO_USE_SITE_OR_RECEIVER: UseSiteFilter = Impl(
+                acceptedSiteTarget = AnnotationUseSiteTarget.RECEIVER,
+                acceptedTargets = setOf(AnnotationTarget.VALUE_PARAMETER)
+            )
             val FILE: UseSiteFilter = Impl(
                 acceptedSiteTarget = AnnotationUseSiteTarget.FILE,
                 acceptedTargets = setOf(AnnotationTarget.FILE),
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspMethodElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspMethodElement.kt
index 472435f..9f77ab5 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspMethodElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspMethodElement.kt
@@ -22,6 +22,7 @@
 import androidx.room.compiler.processing.XMethodType
 import androidx.room.compiler.processing.XType
 import androidx.room.compiler.processing.XTypeElement
+import androidx.room.compiler.processing.ksp.KspProcessingEnv.JvmDefaultMode
 import androidx.room.compiler.processing.ksp.synthetic.KspSyntheticContinuationParameterElement
 import androidx.room.compiler.processing.ksp.synthetic.KspSyntheticReceiverParameterElement
 import com.google.devtools.ksp.KspExperimental
@@ -106,8 +107,14 @@
     }
 
     override fun isJavaDefault(): Boolean {
+        val parentDeclaration = declaration.parentDeclaration
         return declaration.modifiers.contains(Modifier.JAVA_DEFAULT) ||
-            declaration.hasJvmDefaultAnnotation()
+            declaration.hasJvmDefaultAnnotation() ||
+            (parentDeclaration is KSClassDeclaration &&
+                parentDeclaration.classKind == ClassKind.INTERFACE &&
+                !declaration.isAbstract &&
+                !isPrivate() &&
+                env.jvmDefaultMode != JvmDefaultMode.DISABLE)
     }
 
     override fun asMemberOf(other: XType): XMethodType {
@@ -126,13 +133,14 @@
         return parentDeclaration is KSClassDeclaration &&
             parentDeclaration.classKind == ClassKind.INTERFACE &&
             !declaration.isAbstract &&
-            !isPrivate()
+            !isPrivate() &&
+            env.jvmDefaultMode != JvmDefaultMode.ALL_INCOMPATIBLE
     }
 
     override fun isExtensionFunction() = declaration.extensionReceiver != null
 
     override fun overrides(other: XMethodElement, owner: XTypeElement): Boolean {
-        return env.resolver.overrides(this, other)
+        return env.resolver.overrides(this, other, owner as? KspTypeElement)
     }
 
     override fun isKotlinPropertyMethod() = false
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
index 0eff801..dafd994 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
@@ -28,6 +28,7 @@
 import androidx.room.compiler.processing.javac.XTypeElementStore
 import com.google.devtools.ksp.KspExperimental
 import com.google.devtools.ksp.getClassDeclarationByName
+import com.google.devtools.ksp.processing.JvmPlatformInfo
 import com.google.devtools.ksp.processing.Resolver
 import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
 import com.google.devtools.ksp.symbol.ClassKind
@@ -51,10 +52,19 @@
     private val logger = delegate.logger
     private val codeGenerator = delegate.codeGenerator
 
-    // No API to get this but Kotlin's default is 8, so go with it for now.
-    // TODO: https://github.com/google/ksp/issues/810
-    override val jvmVersion: Int
-        get() = 8
+    private val jvmPlatformInfo by lazy {
+        delegate.platforms.filterIsInstance<JvmPlatformInfo>().firstOrNull()
+    }
+
+    override val jvmVersion by lazy {
+       when (val jvmTarget = jvmPlatformInfo?.jvmTarget) {
+           // Special case "1.8" since it is the only valid value with the 1.x notation, it is
+           // also the default value.
+           // See https://kotlinlang.org/docs/compiler-reference.html#jvm-target-version
+           "1.8", null -> 8
+           else -> jvmTarget.toInt()
+       }
+    }
 
     private val ksFileMemberContainers = mutableMapOf<KSFile, KspFileMemberContainer>()
 
@@ -108,6 +118,10 @@
             boxed = false,
         )
 
+    internal val jvmDefaultMode by lazy {
+        jvmPlatformInfo?.let { JvmDefaultMode.fromStringOrNull(it.jvmDefaultMode) }
+    }
+
     override fun findTypeElement(qName: String): KspTypeElement? {
         return typeElementStore[qName]
     }
@@ -334,4 +348,19 @@
     inner class CommonTypes() {
         val anyType: XType = requireType("kotlin.Any")
     }
+
+    internal enum class JvmDefaultMode(val option: String) {
+        DISABLE("disable"),
+        ALL_COMPATIBILITY("all-compatibility"),
+        ALL_INCOMPATIBLE("all");
+
+        companion object {
+            fun fromStringOrNull(string: String?): JvmDefaultMode? = when (string) {
+                DISABLE.option -> DISABLE
+                ALL_COMPATIBILITY.option -> ALL_COMPATIBILITY
+                ALL_INCOMPATIBLE.option -> ALL_INCOMPATIBLE
+                else -> null
+            }
+        }
+    }
 }
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/ResolverExt.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/ResolverExt.kt
index 21df2cf..2ba08eb 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/ResolverExt.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/ResolverExt.kt
@@ -45,7 +45,8 @@
 
 internal fun Resolver.overrides(
     overriderElement: XMethodElement,
-    overrideeElement: XMethodElement
+    overrideeElement: XMethodElement,
+    owner: KspTypeElement? = null
 ): Boolean {
     // in addition to functions declared in kotlin, we also synthesize getter/setter functions for
     // properties which means we cannot simply send the declaration to KSP for override check
@@ -60,7 +61,10 @@
 
     val ksOverrider = overriderElement.getDeclarationForOverride()
     val ksOverridee = overrideeElement.getDeclarationForOverride()
-    if (overrides(ksOverrider, ksOverridee)) {
+    if (
+        owner?.let { overrides(ksOverrider, ksOverridee, it.declaration) } == true ||
+        overrides(ksOverrider, ksOverridee)
+    ) {
         // Make sure it also overrides in JVM descriptors as well.
         // This happens in cases where parent class has `<T>` type argument and child class
         // declares it has `Int` (a type that might map to a primitive). In those cases,
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
index 82bac10..24b75a3 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
@@ -37,6 +37,7 @@
 import androidx.room.compiler.processing.ksp.KspMemberContainer
 import androidx.room.compiler.processing.ksp.KspProcessingEnv
 import androidx.room.compiler.processing.ksp.KspType
+import androidx.room.compiler.processing.ksp.KspTypeElement
 import androidx.room.compiler.processing.ksp.findEnclosingMemberContainer
 import androidx.room.compiler.processing.ksp.jvmDescriptor
 import androidx.room.compiler.processing.ksp.overrides
@@ -151,7 +152,7 @@
     }
 
     final override fun overrides(other: XMethodElement, owner: XTypeElement): Boolean {
-        return env.resolver.overrides(this, other)
+        return env.resolver.overrides(this, other, field.enclosingElement as? KspTypeElement)
     }
 
     override fun isKotlinPropertyMethod() = true
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticReceiverParameterElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticReceiverParameterElement.kt
index 1bbd99e..a5e8f73 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticReceiverParameterElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticReceiverParameterElement.kt
@@ -36,8 +36,8 @@
     XEquality,
     XAnnotated by KspAnnotated.create(
         env = env,
-        delegate = null, // does not matter, this is synthetic and has no annotations.
-        filter = KspAnnotated.UseSiteFilter.NO_USE_SITE
+        delegate = receiverType, // for @receiver use-site annotations
+        filter = KspAnnotated.UseSiteFilter.NO_USE_SITE_OR_RECEIVER
     ) {
 
     override fun isContinuationParam() = false
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
index c64b056..da629f7 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
@@ -18,7 +18,6 @@
 
 import androidx.kruth.assertThat
 import androidx.room.compiler.codegen.JArrayTypeName
-import androidx.room.compiler.processing.compat.XConverters.toJavac
 import androidx.room.compiler.processing.util.Source
 import androidx.room.compiler.processing.util.XTestInvocation
 import androidx.room.compiler.processing.util.asJClassName
@@ -44,7 +43,6 @@
 import com.squareup.kotlinpoet.SHORT
 import com.squareup.kotlinpoet.SHORT_ARRAY
 import com.squareup.kotlinpoet.STAR
-import com.squareup.kotlinpoet.javapoet.JAnnotationSpec
 import com.squareup.kotlinpoet.javapoet.JClassName
 import com.squareup.kotlinpoet.javapoet.JParameterizedTypeName
 import com.squareup.kotlinpoet.javapoet.JTypeName
@@ -1354,7 +1352,7 @@
 
     @Test
     fun testDefaultValues() {
-            runTest(
+        runTest(
             javaSource = Source.java(
                 "test.MyClass",
                 """
@@ -1389,43 +1387,30 @@
             ) as Source.KotlinSource
         ) { invocation ->
             val annotation = getAnnotation(invocation)
-            if (sourceKind == SourceKind.JAVA && invocation.isKsp && !isPreCompiled) {
-                // TODO(https://github.com/google/ksp/issues/1392) Remove the condition
-                // when bugs are fixed in ksp/kapt.
-                assertThat(annotation.getAnnotationValue("stringParam").value)
-                    .isEqualTo("2")
-                assertThat(annotation.getAnnotationValue("stringParam2").value).isEqualTo("1")
-                assertThat(
-                    annotation.getAnnotationValue("stringArrayParam")
-                        .asAnnotationValueList().firstOrNull()?.value).isNull()
-            } else {
                 // Compare the AnnotationSpec string ignoring whitespace
                 assertThat(annotation.toAnnotationSpec().toString().removeWhiteSpace())
                     .isEqualTo("""
                         @test.MyAnnotation(
-                            stringParam = "2",
-                            stringParam2 = "1",
-                            stringArrayParam = {"3", "5", "7"}
+                            stringParam="2",
+                            stringParam2="1",
+                            stringArrayParam={"3","5","7"}
                         )
                         """.removeWhiteSpace())
+
                 assertThat(
                     annotation.toAnnotationSpec(
                         includeDefaultValues = false).toString().removeWhiteSpace())
-                .isEqualTo("""
-                    @test.MyAnnotation(
-                    stringParam = "2"
-                    )
-                    """.removeWhiteSpace())
-
-                 if (!invocation.isKsp && !isTypeAnnotation) {
-                    // Check that "XAnnotation#toAnnotationSpec()" matches JavaPoet's
-                    // "AnnotationSpec.get(AnnotationMirror)"
-                    assertThat(annotation.toAnnotationSpec(includeDefaultValues = false))
-                      .isEqualTo(JAnnotationSpec.get(annotation.toJavac()))
-                    assertThat(annotation.toAnnotationSpec())
-                      .isNotEqualTo(JAnnotationSpec.get(annotation.toJavac()))
-                }
-            }
+                    .isEqualTo("""
+                        @test.MyAnnotation(stringParam="2")
+                        """.removeWhiteSpace())
+                assertThat(annotation.getAnnotationValue("stringParam").value)
+                    .isEqualTo("2")
+                assertThat(annotation.getAnnotationValue("stringParam2").value)
+                    .isEqualTo("1")
+                assertThat(
+                    annotation.getAnnotationValue("stringArrayParam")
+                        .asAnnotationValueList().firstOrNull()?.value)
+                    .isEqualTo("3")
         }
     }
 
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XExecutableElementTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XExecutableElementTest.kt
index 181265e6..76b1b92 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XExecutableElementTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XExecutableElementTest.kt
@@ -989,6 +989,7 @@
             "Foo.kt",
             """
             package $pkg
+            annotation class MyAnnotation
             abstract class Foo<T> {
                 fun String.ext1(): String = TODO()
                 fun String.ext2(inputParam: Int): String = TODO()
@@ -997,6 +998,7 @@
                 fun T.ext5(): String = TODO()
                 suspend fun String.ext6(): String = TODO()
                 abstract fun T.ext7(): String
+                fun @receiver:MyAnnotation String.ext8(): String = TODO()
             }
             class FooImpl : Foo<Int>() {
                 override fun Int.ext7(): String = TODO()
@@ -1006,9 +1008,9 @@
         runProcessorTest(
             sources = listOf(buildSource(pkg = "app")),
             classpath = compileFiles(listOf(buildSource(pkg = "lib")))
-        ) {
+        ) { invocation ->
             listOf("app", "lib").forEach { pkg ->
-                val element = it.processingEnv.requireTypeElement("$pkg.Foo")
+                val element = invocation.processingEnv.requireTypeElement("$pkg.Foo")
                 element.getDeclaredMethodByJvmName("ext1").let { method ->
                     assertThat(method.isExtensionFunction()).isTrue()
                     assertThat(method.parameters.size).isEqualTo(1)
@@ -1039,7 +1041,7 @@
                             JTypeVariableName.get("T")
                         )
                     )
-                    if (it.isKsp) {
+                    if (invocation.isKsp) {
                         assertThat(method.parameters[0].type.asTypeName().kotlin).isEqualTo(
                             KClassName(pkg, "Foo").parameterizedBy(KTypeVariableName("T"))
                         )
@@ -1070,19 +1072,26 @@
                     assertThat(method.parameters[0].type.asTypeName())
                         .isEqualTo(XTypeName.getTypeVariableName("T"))
 
-                    val fooImpl = it.processingEnv.requireTypeElement("$pkg.FooImpl")
+                    val fooImpl = invocation.processingEnv.requireTypeElement("$pkg.FooImpl")
                     assertThat(method.parameters[0].asMemberOf(fooImpl.type).asTypeName())
                         .isEqualTo(Int::class.asClassName())
                 }
+                element.getDeclaredMethodByJvmName("ext8").let { method ->
+                    // TODO: KSP bug where receiver annotation is not available from compiled code.
+                    //  see https://github.com/google/ksp/issues/1488
+                    if (invocation.isKsp && pkg == "lib") return@let
+                    val receiverParam = method.parameters.single()
+                    assertThat(receiverParam.getAllAnnotations()).isNotEmpty()
+                }
                 // Verify non-overridden Foo.ext1() asMemberOf FooImpl
                 element.getDeclaredMethodByJvmName("ext1").let { method ->
-                    val fooImpl = it.processingEnv.requireTypeElement("$pkg.FooImpl")
+                    val fooImpl = invocation.processingEnv.requireTypeElement("$pkg.FooImpl")
                     assertThat(method.parameters[0].asMemberOf(fooImpl.type).asTypeName())
                         .isEqualTo(String::class.asClassName())
                 }
                 // Verify non-overridden Foo.ext5() asMemberOf FooImpl
                 element.getDeclaredMethodByJvmName("ext5").let { method ->
-                    val fooImpl = it.processingEnv.requireTypeElement("$pkg.FooImpl")
+                    val fooImpl = invocation.processingEnv.requireTypeElement("$pkg.FooImpl")
                     assertThat(method.parameters[0].asMemberOf(fooImpl.type).asTypeName())
                         .isEqualTo(Int::class.asClassName())
                 }
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
index 5b165ae..7b3d742 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
@@ -2383,6 +2383,32 @@
         }
     }
 
+    @Test
+    fun overrideConsideringOwner() {
+        val src = Source.kotlin(
+            "Subject.kt",
+            """
+            abstract class MyDatabase : RoomDatabase(), MyInterface
+
+            abstract class RoomDatabase {
+              open fun runInTransaction(body: Runnable) {
+
+              }
+            }
+
+            interface MyInterface {
+              fun runInTransaction(body: Runnable)
+            }
+            """.trimIndent()
+        )
+        runProcessorTest(sources = listOf(src)) {
+            val subject = it.processingEnv.requireTypeElement("MyDatabase")
+            val methods = subject.getAllMethods().filter { it.name == "runInTransaction" }.toList()
+            assertThat(methods.size).isEqualTo(1)
+            assertThat(methods.single().isAbstract()).isFalse()
+        }
+    }
+
     /**
      * it is good to exclude methods coming from Object when testing as they differ between KSP
      * and KAPT but irrelevant for Room.
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/TransactionMethodProcessor.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/TransactionMethodProcessor.kt
index 8743265..59ca629 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/TransactionMethodProcessor.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/TransactionMethodProcessor.kt
@@ -55,15 +55,9 @@
         }
 
         val callType = when {
-            executableElement.isJavaDefault() ->
-                if (containingElement.isInterface()) {
-                    // if the dao is an interface, call via the Dao interface
-                    TransactionMethod.CallType.DEFAULT_JAVA8
-                } else {
-                    // if the dao is an abstract class, call via the class itself
-                    TransactionMethod.CallType.INHERITED_DEFAULT_JAVA8
-                }
-            hasKotlinDefaultImpl ->
+            containingElement.isInterface() && executableElement.isJavaDefault() ->
+                TransactionMethod.CallType.DEFAULT_JAVA8
+            containingElement.isInterface() && hasKotlinDefaultImpl ->
                 TransactionMethod.CallType.DEFAULT_KOTLIN
             else ->
                 TransactionMethod.CallType.CONCRETE
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/transaction/result/TransactionMethodAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/transaction/result/TransactionMethodAdapter.kt
index 7384632..98b419c 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/transaction/result/TransactionMethodAdapter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/transaction/result/TransactionMethodAdapter.kt
@@ -84,8 +84,7 @@
         daoName: XClassName,
         daoImplName: XClassName,
     ): XCodeBlock = when (callType) {
-        TransactionMethod.CallType.CONCRETE,
-        TransactionMethod.CallType.INHERITED_DEFAULT_JAVA8 -> {
+        TransactionMethod.CallType.CONCRETE -> {
             XCodeBlock.of(
                 language,
                 "%T.super.%N(",
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/vo/TransactionMethod.kt b/room/room-compiler/src/main/kotlin/androidx/room/vo/TransactionMethod.kt
index 480a505..de891f7 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/vo/TransactionMethod.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/vo/TransactionMethod.kt
@@ -29,24 +29,18 @@
 ) {
     enum class CallType {
         /**
-         * Directly call the method, it has an implementation
+         * Directly call the method, it has a super implementation
          */
         CONCRETE,
 
         /**
-         * It has a default implementation and the default implementation is in the DAO
+         * It has a default implementation and the default implementation is in the DAO interface
          */
         DEFAULT_JAVA8,
 
         /**
-         * Has DefaultImpl generated by kotlin
+         * Has DefaultImpl generated by Kotlin
          */
         DEFAULT_KOTLIN,
-
-        /**
-         * It has a default implementation which is not declared in the dao, rather, it inherits
-         * it from a super.
-         */
-        INHERITED_DEFAULT_JAVA8
     }
 }
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/writer/DefaultsInDaoTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/writer/DefaultsInDaoTest.kt
index 6d6fcc7..1ca847b 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/writer/DefaultsInDaoTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/writer/DefaultsInDaoTest.kt
@@ -20,7 +20,7 @@
 import androidx.room.compiler.codegen.CodeLanguage
 import androidx.room.compiler.processing.XTypeElement
 import androidx.room.compiler.processing.util.Source
-import androidx.room.compiler.processing.util.runKaptTest
+import androidx.room.compiler.processing.util.runProcessorTest
 import androidx.room.ext.RoomTypeNames.ROOM_DB
 import androidx.room.processor.DaoProcessor
 import androidx.room.testing.context
@@ -170,8 +170,7 @@
         jvmTarget: String = "1.8",
         handler: (StringSubject) -> Unit
     ) {
-        // TODO should run these with KSP as well. https://github.com/google/ksp/issues/627
-        runKaptTest(
+        runProcessorTest(
             sources = listOf(source, COMMON.COROUTINES_ROOM, COMMON.ROOM_DATABASE_KTX),
             javacArguments = listOf(
                 "-source", jvmTarget
diff --git a/savedstate/savedstate/src/main/java/androidx/savedstate/SavedStateRegistry.kt b/savedstate/savedstate/src/main/java/androidx/savedstate/SavedStateRegistry.kt
index f5d4471..f269a28 100644
--- a/savedstate/savedstate/src/main/java/androidx/savedstate/SavedStateRegistry.kt
+++ b/savedstate/savedstate/src/main/java/androidx/savedstate/SavedStateRegistry.kt
@@ -230,12 +230,9 @@
      * merge with unconsumed state.
      *
      * @param outBundle Bundle in which to place a saved state
-     * @suppress INACCESSIBLE_TYPE iterator is used strictly as Iterator, does not access
-     * inaccessible type IteratorWithAdditions
      */
     @MainThread
-    @Suppress("INACCESSIBLE_TYPE")
-    fun performSave(outBundle: Bundle) {
+    internal fun performSave(outBundle: Bundle) {
         val components = Bundle()
         if (restoredState != null) {
             components.putAll(restoredState)
diff --git a/settings.gradle b/settings.gradle
index 525a796..60be383 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -777,6 +777,7 @@
 includeProject(":glance:glance-material3", [BuildType.GLANCE])
 includeProject(":glance:glance-template", [BuildType.GLANCE])
 includeProject(":glance:glance-template:integration-tests:template-demos", [BuildType.GLANCE])
+includeProject(":glance:glance-testing", [BuildType.GLANCE])
 includeProject(":glance:glance-wear-tiles:integration-tests:demos", [BuildType.GLANCE])
 includeProject(":glance:glance-wear-tiles:integration-tests:template-demos", [BuildType.GLANCE])
 includeProject(":glance:glance-wear-tiles", [BuildType.GLANCE])
@@ -910,6 +911,9 @@
 includeProject(":privacysandbox:sdkruntime:sdkruntime-client", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:sdkruntime-core", [BuildType.MAIN])
 includeProject(":privacysandbox:sdkruntime:test-sdks:current", [BuildType.MAIN])
+includeProject(":privacysandbox:sdkruntime:test-sdks:v1", [BuildType.MAIN])
+includeProject(":privacysandbox:sdkruntime:test-sdks:v2", [BuildType.MAIN])
+includeProject(":privacysandbox:sdkruntime:test-sdks:v4", [BuildType.MAIN])
 includeProject(":privacysandbox:tools:tools", [BuildType.MAIN])
 includeProject(":privacysandbox:tools:tools-apicompiler", [BuildType.MAIN])
 includeProject(":privacysandbox:tools:tools-apigenerator", [BuildType.MAIN])
diff --git a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/MultiDisplayTest.java b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/MultiDisplayTest.java
index 6b229e7..8d9ef75 100644
--- a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/MultiDisplayTest.java
+++ b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/MultiDisplayTest.java
@@ -27,14 +27,12 @@
 import android.content.Intent;
 import android.hardware.display.DisplayManager;
 import android.view.Display;
-import android.view.Surface;
 
 import androidx.annotation.NonNull;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SdkSuppress;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.BySelector;
-import androidx.test.uiautomator.Orientation;
 import androidx.test.uiautomator.UiObject2;
 import androidx.test.uiautomator.Until;
 
@@ -131,24 +129,6 @@
         }
     }
 
-    @Test
-    public void testMultiDisplay_orientations() {
-        int secondaryDisplayId = getSecondaryDisplayId();
-
-        try {
-            mDevice.setOrientation(Orientation.ROTATION_180, secondaryDisplayId);
-            assertEquals(Surface.ROTATION_180, mDevice.getDisplayRotation(secondaryDisplayId));
-
-            mDevice.setOrientation(Orientation.FROZEN, secondaryDisplayId);
-            assertEquals(Surface.ROTATION_180, mDevice.getDisplayRotation(secondaryDisplayId));
-
-            mDevice.setOrientation(Orientation.ROTATION_270, secondaryDisplayId);
-            assertEquals(Surface.ROTATION_270, mDevice.getDisplayRotation(secondaryDisplayId));
-        } finally {
-            mDevice.setOrientation(Orientation.ROTATION_0, secondaryDisplayId);
-        }
-    }
-
     // Helper to launch an activity on a specific display.
     private void launchTestActivityOnDisplay(@NonNull Class<? extends Activity> activity,
             int displayId) {
diff --git a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiDeviceTest.java b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiDeviceTest.java
index 8f94cc4..f14e6e6 100644
--- a/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiDeviceTest.java
+++ b/test/uiautomator/integration-tests/testapp/src/androidTest/java/androidx/test/uiautomator/testapp/UiDeviceTest.java
@@ -25,7 +25,6 @@
 import android.app.UiAutomation;
 import android.graphics.Point;
 import android.view.KeyEvent;
-import android.view.Surface;
 import android.widget.TextView;
 
 import androidx.test.filters.LargeTest;
@@ -33,7 +32,6 @@
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.BySelector;
-import androidx.test.uiautomator.Orientation;
 import androidx.test.uiautomator.UiDevice;
 import androidx.test.uiautomator.UiObject2;
 import androidx.test.uiautomator.Until;
@@ -392,23 +390,24 @@
     }
 
     @Test
-    public void testSetOrientation() {
+    public void testSetOrientationPortrait() throws Exception {
         launchTestActivity(KeycodeTestActivity.class);
-
         try {
-            mDevice.setOrientation(Orientation.ROTATION_0);
-            assertEquals(Surface.ROTATION_0, mDevice.getDisplayRotation());
-
-            mDevice.setOrientation(Orientation.ROTATION_90);
-            assertEquals(Surface.ROTATION_90, mDevice.getDisplayRotation());
-
-            mDevice.setOrientation(Orientation.PORTRAIT);
-            assertTrue(mDevice.getDisplayHeight() >= mDevice.getDisplayWidth());
-
-            mDevice.setOrientation(Orientation.LANDSCAPE);
-            assertTrue(mDevice.getDisplayHeight() <= mDevice.getDisplayWidth());
+            mDevice.setOrientationPortrait();
+            assertTrue(mDevice.getDisplayHeight() > mDevice.getDisplayWidth());
         } finally {
-            mDevice.setOrientation(Orientation.ROTATION_0);
+            mDevice.unfreezeRotation();
+        }
+    }
+
+    @Test
+    public void testSetOrientationLandscape() throws Exception {
+        launchTestActivity(KeycodeTestActivity.class);
+        try {
+            mDevice.setOrientationLandscape();
+            assertTrue(mDevice.getDisplayWidth() > mDevice.getDisplayHeight());
+        } finally {
+            mDevice.unfreezeRotation();
         }
     }
 
diff --git a/test/uiautomator/uiautomator/api/current.txt b/test/uiautomator/uiautomator/api/current.txt
index f50179d..8f7bdde 100644
--- a/test/uiautomator/uiautomator/api/current.txt
+++ b/test/uiautomator/uiautomator/api/current.txt
@@ -132,17 +132,6 @@
     method public void sendStatus(int, android.os.Bundle);
   }
 
-  public enum Orientation {
-    enum_constant public static final androidx.test.uiautomator.Orientation FROZEN;
-    enum_constant public static final androidx.test.uiautomator.Orientation LANDSCAPE;
-    enum_constant public static final androidx.test.uiautomator.Orientation PORTRAIT;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_0;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_180;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_270;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_90;
-    enum_constant public static final androidx.test.uiautomator.Orientation UNFROZEN;
-  }
-
   public abstract class SearchCondition<U> implements androidx.test.uiautomator.Condition<androidx.test.uiautomator.Searchable,U> {
     ctor public SearchCondition();
   }
@@ -190,7 +179,6 @@
     method @Px public int getDisplayHeight();
     method @Px public int getDisplayHeight(int);
     method public int getDisplayRotation();
-    method public int getDisplayRotation(int);
     method public android.graphics.Point getDisplaySizeDp();
     method @Px public int getDisplayWidth();
     method @Px public int getDisplayWidth(int);
@@ -229,10 +217,10 @@
     method public void runWatchers();
     method @Deprecated public void setCompressedLayoutHeirarchy(boolean);
     method public void setCompressedLayoutHierarchy(boolean);
-    method public void setOrientation(androidx.test.uiautomator.Orientation);
-    method @RequiresApi(30) public void setOrientation(androidx.test.uiautomator.Orientation, int);
+    method public void setOrientationLandscape() throws android.os.RemoteException;
     method public void setOrientationLeft() throws android.os.RemoteException;
     method public void setOrientationNatural() throws android.os.RemoteException;
+    method public void setOrientationPortrait() throws android.os.RemoteException;
     method public void setOrientationRight() throws android.os.RemoteException;
     method public void sleep() throws android.os.RemoteException;
     method public boolean swipe(android.graphics.Point![], int);
diff --git a/test/uiautomator/uiautomator/api/restricted_current.txt b/test/uiautomator/uiautomator/api/restricted_current.txt
index f50179d..8f7bdde 100644
--- a/test/uiautomator/uiautomator/api/restricted_current.txt
+++ b/test/uiautomator/uiautomator/api/restricted_current.txt
@@ -132,17 +132,6 @@
     method public void sendStatus(int, android.os.Bundle);
   }
 
-  public enum Orientation {
-    enum_constant public static final androidx.test.uiautomator.Orientation FROZEN;
-    enum_constant public static final androidx.test.uiautomator.Orientation LANDSCAPE;
-    enum_constant public static final androidx.test.uiautomator.Orientation PORTRAIT;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_0;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_180;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_270;
-    enum_constant public static final androidx.test.uiautomator.Orientation ROTATION_90;
-    enum_constant public static final androidx.test.uiautomator.Orientation UNFROZEN;
-  }
-
   public abstract class SearchCondition<U> implements androidx.test.uiautomator.Condition<androidx.test.uiautomator.Searchable,U> {
     ctor public SearchCondition();
   }
@@ -190,7 +179,6 @@
     method @Px public int getDisplayHeight();
     method @Px public int getDisplayHeight(int);
     method public int getDisplayRotation();
-    method public int getDisplayRotation(int);
     method public android.graphics.Point getDisplaySizeDp();
     method @Px public int getDisplayWidth();
     method @Px public int getDisplayWidth(int);
@@ -229,10 +217,10 @@
     method public void runWatchers();
     method @Deprecated public void setCompressedLayoutHeirarchy(boolean);
     method public void setCompressedLayoutHierarchy(boolean);
-    method public void setOrientation(androidx.test.uiautomator.Orientation);
-    method @RequiresApi(30) public void setOrientation(androidx.test.uiautomator.Orientation, int);
+    method public void setOrientationLandscape() throws android.os.RemoteException;
     method public void setOrientationLeft() throws android.os.RemoteException;
     method public void setOrientationNatural() throws android.os.RemoteException;
+    method public void setOrientationPortrait() throws android.os.RemoteException;
     method public void setOrientationRight() throws android.os.RemoteException;
     method public void sleep() throws android.os.RemoteException;
     method public boolean swipe(android.graphics.Point![], int);
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/AccessibilityNodeInfoDumper.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/AccessibilityNodeInfoDumper.java
index b7b61c0..2dfdeb9 100644
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/AccessibilityNodeInfoDumper.java
+++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/AccessibilityNodeInfoDumper.java
@@ -19,7 +19,9 @@
 import android.os.Build;
 import android.util.Log;
 import android.util.Xml;
+import android.view.Display;
 import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
 
 import androidx.annotation.DoNotInline;
 import androidx.annotation.RequiresApi;
@@ -224,7 +226,9 @@
 
         @DoNotInline
         static int getDisplayId(AccessibilityNodeInfo accessibilityNodeInfo) {
-            return accessibilityNodeInfo.getWindow().getDisplayId();
+            AccessibilityWindowInfo accessibilityWindowInfo = accessibilityNodeInfo.getWindow();
+            return accessibilityWindowInfo == null ? Display.DEFAULT_DISPLAY :
+                    accessibilityWindowInfo.getDisplayId();
         }
     }
 }
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/ByMatcher.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/ByMatcher.java
index ba5cf89..46ffa92 100644
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/ByMatcher.java
+++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/ByMatcher.java
@@ -18,7 +18,9 @@
 
 import android.os.Build;
 import android.util.Log;
+import android.view.Display;
 import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
 
 import androidx.annotation.DoNotInline;
 import androidx.annotation.NonNull;
@@ -427,7 +429,9 @@
 
         @DoNotInline
         static int getDisplayId(AccessibilityNodeInfo accessibilityNodeInfo) {
-            return accessibilityNodeInfo.getWindow().getDisplayId();
+            AccessibilityWindowInfo accessibilityWindowInfo = accessibilityNodeInfo.getWindow();
+            return accessibilityWindowInfo == null ? Display.DEFAULT_DISPLAY :
+                    accessibilityWindowInfo.getDisplayId();
         }
     }
 }
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Orientation.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Orientation.java
deleted file mode 100644
index d660ed9..0000000
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/Orientation.java
+++ /dev/null
@@ -1,42 +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.test.uiautomator;
-
-/**
- * Specifies the orientation state of a display. Used with
- * {@link UiDevice#setOrientation(Orientation)} and
- * {@link UiDevice#setOrientation(Orientation, int)}.
- */
-public enum Orientation {
-    /** Sets the rotation to be the natural orientation. */
-    ROTATION_0,
-    /** Sets the rotation to be 90 degrees clockwise to the natural orientation. */
-    ROTATION_90,
-    /** Sets the rotation to be 180 degrees clockwise to the natural orientation. */
-    ROTATION_180,
-    /** Sets the rotation to be 270 degrees clockwise to the natural orientation. */
-    ROTATION_270,
-    /** Sets the rotation so that the display height will be >= the display width. */
-    PORTRAIT,
-    /** Sets the rotation so that the display height will be <= the display width. */
-    LANDSCAPE,
-    /** Freezes the current rotation. */
-    FROZEN,
-    /** Unfreezes the current rotation. Need to wait a short period for the rotation animation to
-     *  complete before performing another operation. */
-    UNFROZEN;
-}
diff --git a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiDevice.java b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiDevice.java
index dc93516..104f485 100644
--- a/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiDevice.java
+++ b/test/uiautomator/uiautomator/src/main/java/androidx/test/uiautomator/UiDevice.java
@@ -798,273 +798,123 @@
     }
 
     /**
-     * @return true if default display is in its natural or flipped (180 degrees) orientation
+     * @return true if device is in its natural orientation (0 or 180 degrees)
      */
     public boolean isNaturalOrientation() {
-        return isNaturalOrientation(Display.DEFAULT_DISPLAY);
+        int ret = getDisplayRotation();
+        return ret == UiAutomation.ROTATION_FREEZE_0 || ret == UiAutomation.ROTATION_FREEZE_180;
     }
 
     /**
-     * @return true if display with {@code displayId} is in its natural or flipped (180 degrees)
-     * orientation
-     */
-    private boolean isNaturalOrientation(int displayId) {
-        int ret = getDisplayRotation(displayId);
-        return ret == UiAutomation.ROTATION_FREEZE_0
-                || ret == UiAutomation.ROTATION_FREEZE_180;
-    }
-
-    /**
-     * @return the current rotation of the default display
-     * @see Display#getRotation()
+     * @return the current rotation of the display, as defined in {@link Surface}
      */
     public int getDisplayRotation() {
-        return getDisplayRotation(Display.DEFAULT_DISPLAY);
-    }
-
-    /**
-     * @return the current rotation of the display with {@code displayId}
-     * @see Display#getRotation()
-     */
-    public int getDisplayRotation(int displayId) {
         waitForIdle();
-        return getDisplayById(displayId).getRotation();
+        return getDisplayById(Display.DEFAULT_DISPLAY).getRotation();
     }
 
     /**
-     * Freezes the default display rotation at its current state.
+     * Freezes the device rotation at its current state.
      * @throws RemoteException never
      */
     public void freezeRotation() throws RemoteException {
-        setOrientation(Orientation.FROZEN);
+        Log.d(TAG, "Freezing rotation.");
+        getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_CURRENT);
     }
 
     /**
-     * Un-freezes the default display rotation allowing its contents to rotate with its physical
-     * rotation. During testing, it is best to keep the default display frozen in a specific
-     * orientation.
-     * <p>Note: Need to wait a short period for the rotation animation to complete before
-     * performing another operation.
+     * Un-freezes the device rotation allowing its contents to rotate with the device physical
+     * rotation. During testing, it is best to keep the device frozen in a specific orientation.
      * @throws RemoteException never
      */
     public void unfreezeRotation() throws RemoteException {
-        setOrientation(Orientation.UNFROZEN);
+        Log.d(TAG, "Unfreezing rotation.");
+        getUiAutomation().setRotation(UiAutomation.ROTATION_UNFREEZE);
     }
 
     /**
-     * Orients the default display to the left and freezes its rotation. Use
-     * {@link #unfreezeRotation()} to un-freeze the rotation.
+     * Orients the device to the left and freezes rotation. Use {@link #unfreezeRotation()} to
+     * un-freeze the rotation.
      * <p>Note: This rotation is relative to the natural orientation which depends on the device
-     * type (e.g. phone vs. tablet).
+     * type (e.g. phone vs. tablet). Consider using {@link #setOrientationPortrait()} and
+     * {@link #setOrientationLandscape()}.
      * @throws RemoteException never
      */
     public void setOrientationLeft() throws RemoteException {
-        setOrientation(Orientation.ROTATION_90);
+        Log.d(TAG, "Setting orientation to left.");
+        rotate(UiAutomation.ROTATION_FREEZE_90);
     }
 
     /**
-     * Orients the default display to the right and freezes its rotation. Use
-     * {@link #unfreezeRotation()} to un-freeze the rotation.
+     * Orients the device to the right and freezes rotation. Use {@link #unfreezeRotation()} to
+     * un-freeze the rotation.
      * <p>Note: This rotation is relative to the natural orientation which depends on the device
-     * type (e.g. phone vs. tablet).
+     * type (e.g. phone vs. tablet). Consider using {@link #setOrientationPortrait()} and
+     * {@link #setOrientationLandscape()}.
      * @throws RemoteException never
      */
     public void setOrientationRight() throws RemoteException {
-        setOrientation(Orientation.ROTATION_270);
+        Log.d(TAG, "Setting orientation to right.");
+        rotate(UiAutomation.ROTATION_FREEZE_270);
     }
 
     /**
-     * Orients the default display to its natural or flipped (180 degrees) orientation and
-     * freezes its rotation. Use {@link #unfreezeRotation()} to un-freeze the rotation.
+     * Orients the device to its natural orientation (0 or 180 degrees) and freezes rotation. Use
+     * {@link #unfreezeRotation()} to un-freeze the rotation.
      * <p>Note: The natural orientation depends on the device type (e.g. phone vs. tablet).
+     * Consider using {@link #setOrientationPortrait()} and {@link #setOrientationLandscape()}.
      * @throws RemoteException never
      */
     public void setOrientationNatural() throws RemoteException {
-        setOrientation(Orientation.ROTATION_0);
+        Log.d(TAG, "Setting orientation to natural.");
+        rotate(UiAutomation.ROTATION_FREEZE_0);
     }
 
     /**
-     * Sets the default display to {@code orientation} and freezes its rotation.
-     * <p>Note: The orientation is relative to the natural orientation which depends on the
-     * device type (e.g. phone vs. tablet).
-     *
-     * @param orientation the desired orientation
+     * Orients the device to its portrait orientation (height > width) and freezes rotation. Use
+     * {@link #unfreezeRotation()} to un-freeze the rotation.
+     * @throws RemoteException never
      */
-    public void setOrientation(@NonNull Orientation orientation) {
-        Log.d(TAG, String.format("Setting orientation to %s.", orientation.name()));
-        switch (orientation) {
-            case ROTATION_90:
-                rotateWithUiAutomation(Surface.ROTATION_90);
-                break;
-            case ROTATION_270:
-                rotateWithUiAutomation(Surface.ROTATION_270);
-                break;
-            case ROTATION_0:
-                rotateWithUiAutomation(Surface.ROTATION_0);
-                break;
-            case ROTATION_180:
-                rotateWithUiAutomation(Surface.ROTATION_180);
-                break;
-            case PORTRAIT:
-                if (getDisplayHeight() >= getDisplayWidth()) {
-                    // Freeze. Already in portrait orientation.
-                    getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_CURRENT);
-                } else if (isNaturalOrientation()) {
-                    rotateWithUiAutomation(Surface.ROTATION_90);
-                } else {
-                    rotateWithUiAutomation(Surface.ROTATION_0);
-                }
-                break;
-            case LANDSCAPE:
-                if (getDisplayHeight() <= getDisplayWidth()) {
-                    // Freeze. Already in landscape orientation.
-                    getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_CURRENT);
-                } else if (isNaturalOrientation()) {
-                    rotateWithUiAutomation(Surface.ROTATION_90);
-                } else {
-                    rotateWithUiAutomation(Surface.ROTATION_0);
-                }
-                break;
-            case FROZEN:
-                getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_CURRENT);
-                break;
-            case UNFROZEN:
-                getUiAutomation().setRotation(UiAutomation.ROTATION_UNFREEZE);
-                break;
-            default:
-                throw new IllegalArgumentException(
-                        "Invalid input orientation for UiDevice#setOrientation.");
+    public void setOrientationPortrait() throws RemoteException {
+        Log.d(TAG, "Setting orientation to portrait.");
+        if (getDisplayHeight() > getDisplayWidth()) {
+            freezeRotation(); // Already in portrait orientation.
+        } else if (isNaturalOrientation()) {
+            rotate(UiAutomation.ROTATION_FREEZE_90);
+        } else {
+            rotate(UiAutomation.ROTATION_FREEZE_0);
         }
     }
 
     /**
-     * Sets the display with {@code displayId} to {@code orientation} and freezes its rotation.
-     * <p>Note: The orientation is relative to the natural orientation which depends on the
-     * device type (e.g. phone vs. tablet).
-     * <p>Note: Some secondary displays don't have rotation sensors and therefore won't respond
-     * to {@link Orientation#UNFROZEN}.
-     *
-     * @param orientation the desired orientation
-     * @param displayId The display ID to match. Use {@link Display#getDisplayId()} to get the ID.
+     * Orients the device to its landscape orientation (width > height) and freezes rotation. Use
+     * {@link #unfreezeRotation()} to un-freeze the rotation.
+     * @throws RemoteException never
      */
-    @RequiresApi(30)
-    public void setOrientation(@NonNull Orientation orientation, int displayId) {
-        Log.d(TAG, String.format("Setting orientation of display %d to %s.", displayId,
-                orientation.name()));
-        switch (orientation) {
-            case ROTATION_90:
-                rotateWithCommand(Surface.ROTATION_90, displayId);
-                break;
-            case ROTATION_270:
-                rotateWithCommand(Surface.ROTATION_270, displayId);
-                break;
-            case ROTATION_0:
-                rotateWithCommand(Surface.ROTATION_0, displayId);
-                break;
-            case ROTATION_180:
-                rotateWithCommand(Surface.ROTATION_180, displayId);
-                break;
-            case PORTRAIT:
-                if (getDisplayHeight() >= getDisplayWidth()) {
-                    // Freeze. Already in portrait orientation.
-                    freezeWithCommand(displayId);
-                } else if (isNaturalOrientation(displayId)) {
-                    rotateWithCommand(Surface.ROTATION_90, displayId);
-                } else {
-                    rotateWithCommand(Surface.ROTATION_0, displayId);
-                }
-                break;
-            case LANDSCAPE:
-                if (getDisplayHeight() <= getDisplayWidth()) {
-                    // Freeze. Already in landscape orientation.
-                    freezeWithCommand(displayId);
-                } else if (isNaturalOrientation(displayId)) {
-                    rotateWithCommand(Surface.ROTATION_90, displayId);
-                } else {
-                    rotateWithCommand(Surface.ROTATION_0, displayId);
-                }
-                break;
-            case FROZEN:
-                freezeWithCommand(displayId);
-                break;
-            case UNFROZEN:
-                unfreezeWithCommand(displayId);
-                break;
-            default:
-                throw new IllegalArgumentException(
-                        "Invalid input orientation for UiDevice#setOrientation.");
+    public void setOrientationLandscape() throws RemoteException {
+        Log.d(TAG, "Setting orientation to landscape.");
+        if (getDisplayWidth() > getDisplayHeight()) {
+            freezeRotation(); // Already in landscape orientation.
+        } else if (isNaturalOrientation()) {
+            rotate(UiAutomation.ROTATION_FREEZE_90);
+        } else {
+            rotate(UiAutomation.ROTATION_FREEZE_0);
         }
     }
 
-    /** Rotates the display using UiAutomation and waits for the rotation to be detected. */
-    private void rotateWithUiAutomation(int rotation) {
+    // Rotates the device and waits for the rotation to be detected.
+    private void rotate(int rotation) {
         getUiAutomation().setRotation(rotation);
-        waitRotationComplete(rotation, Display.DEFAULT_DISPLAY);
-    }
-
-    /** Rotates the display using shell command and waits for the rotation to be detected. */
-    @RequiresApi(30)
-    private void rotateWithCommand(int rotation, int displayId) {
-        try {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                executeShellCommand(String.format("cmd window user-rotation -d %d lock %d",
-                        displayId, rotation));
-            } else {
-                executeShellCommand(String.format("cmd window set-user-rotation lock -d %d %d",
-                        displayId, rotation));
-            }
-        } catch (IOException e) {
-            throw new RuntimeException(e);
-        }
-        waitRotationComplete(rotation, displayId);
-    }
-
-    /** Freezes the display using shell command. */
-    @RequiresApi(30)
-    private void freezeWithCommand(int displayId) {
-        try {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                executeShellCommand(String.format("cmd window user-rotation -d %d lock",
-                        displayId));
-            } else {
-                int rotation = getDisplayRotation(displayId);
-                executeShellCommand(String.format("cmd window set-user-rotation lock -d %d %d",
-                        displayId, rotation));
-            }
-        } catch (IOException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    /** Unfreezes the display using shell command. */
-    @RequiresApi(30)
-    private void unfreezeWithCommand(int displayId) {
-        try {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                executeShellCommand(String.format("cmd window user-rotation -d %d free",
-                        displayId));
-            } else {
-                executeShellCommand(String.format("cmd window set-user-rotation free -d %d",
-                        displayId));
-            }
-        } catch (IOException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    /** Waits for the display with {@code displayId} to be in {@code rotation}. */
-    private void waitRotationComplete(int rotation, int displayId) {
         Condition<UiDevice, Boolean> rotationCondition = new Condition<UiDevice, Boolean>() {
             @Override
             public Boolean apply(UiDevice device) {
-                return device.getDisplayRotation(displayId) == rotation;
+                return device.getDisplayRotation() == rotation;
             }
 
             @NonNull
             @Override
             public String toString() {
-                return String.format("Condition[displayRotation=%d, displayId=%d]", rotation,
-                        displayId);
+                return String.format("Condition[displayRotation=%d]", rotation);
             }
         };
         if (!wait(rotationCondition, ROTATION_TIMEOUT)) {
diff --git a/tv/tv-foundation/api/api_lint.ignore b/tv/tv-foundation/api/api_lint.ignore
index 599cbcc..8fe1f4b 100644
--- a/tv/tv-foundation/api/api_lint.ignore
+++ b/tv/tv-foundation/api/api_lint.ignore
@@ -1,4 +1,8 @@
 // Baseline format: 1.0
+GetterSetterNames: androidx.tv.foundation.TvScrollState#getCanScrollBackward():
+    Getter for boolean property `canScrollBackward` is named `getCanScrollBackward` but should match the property name. Use `@get:JvmName` to rename.
+GetterSetterNames: androidx.tv.foundation.TvScrollState#getCanScrollForward():
+    Getter for boolean property `canScrollForward` is named `getCanScrollForward` but should match the property name. Use `@get:JvmName` to rename.
 GetterSetterNames: androidx.tv.foundation.lazy.grid.TvLazyGridState#getCanScrollBackward():
     Getter for boolean property `canScrollBackward` is named `getCanScrollBackward` but should match the property name. Use `@get:JvmName` to rename.
 GetterSetterNames: androidx.tv.foundation.lazy.grid.TvLazyGridState#getCanScrollForward():
diff --git a/tv/tv-foundation/api/current.txt b/tv/tv-foundation/api/current.txt
index d8098f2..e26e63a 100644
--- a/tv/tv-foundation/api/current.txt
+++ b/tv/tv-foundation/api/current.txt
@@ -47,6 +47,11 @@
     method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
   }
 
+  public static final class TvGridCells.FixedSize implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.FixedSize(float size);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TvGridItemSpan {
     method public int getCurrentLineSpan();
     property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final int currentLineSpan;
@@ -54,12 +59,14 @@
 
   public sealed interface TvLazyGridItemInfo {
     method public int getColumn();
+    method public Object? getContentType();
     method public int getIndex();
     method public Object getKey();
     method public long getOffset();
     method public int getRow();
     method public long getSize();
     property public abstract int column;
+    property public abstract Object? contentType;
     property public abstract int index;
     property public abstract Object key;
     property public abstract long offset;
@@ -164,11 +171,13 @@
     method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.list.TvLazyListState rememberTvLazyListState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
   }
 
-  public interface TvLazyListItemInfo {
+  public sealed interface TvLazyListItemInfo {
+    method public Object? getContentType();
     method public int getIndex();
     method public Object getKey();
     method public int getOffset();
     method public int getSize();
+    property public abstract Object? contentType;
     property public abstract int index;
     property public abstract Object key;
     property public abstract int offset;
diff --git a/tv/tv-foundation/api/restricted_current.txt b/tv/tv-foundation/api/restricted_current.txt
index d8098f2..e26e63a 100644
--- a/tv/tv-foundation/api/restricted_current.txt
+++ b/tv/tv-foundation/api/restricted_current.txt
@@ -47,6 +47,11 @@
     method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
   }
 
+  public static final class TvGridCells.FixedSize implements androidx.tv.foundation.lazy.grid.TvGridCells {
+    ctor public TvGridCells.FixedSize(float size);
+    method public java.util.List<java.lang.Integer> calculateCrossAxisCellSizes(androidx.compose.ui.unit.Density, int availableSize, int spacing);
+  }
+
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class TvGridItemSpan {
     method public int getCurrentLineSpan();
     property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final int currentLineSpan;
@@ -54,12 +59,14 @@
 
   public sealed interface TvLazyGridItemInfo {
     method public int getColumn();
+    method public Object? getContentType();
     method public int getIndex();
     method public Object getKey();
     method public long getOffset();
     method public int getRow();
     method public long getSize();
     property public abstract int column;
+    property public abstract Object? contentType;
     property public abstract int index;
     property public abstract Object key;
     property public abstract long offset;
@@ -164,11 +171,13 @@
     method @androidx.compose.runtime.Composable public static androidx.tv.foundation.lazy.list.TvLazyListState rememberTvLazyListState(optional int initialFirstVisibleItemIndex, optional int initialFirstVisibleItemScrollOffset);
   }
 
-  public interface TvLazyListItemInfo {
+  public sealed interface TvLazyListItemInfo {
+    method public Object? getContentType();
     method public int getIndex();
     method public Object getKey();
     method public int getOffset();
     method public int getSize();
+    property public abstract Object? contentType;
     property public abstract int index;
     property public abstract Object key;
     property public abstract int offset;
diff --git a/tv/tv-foundation/build.gradle b/tv/tv-foundation/build.gradle
index 365414c..93f9f90 100644
--- a/tv/tv-foundation/build.gradle
+++ b/tv/tv-foundation/build.gradle
@@ -107,15 +107,55 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt",
                 "src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt",
-                "01908be77830b70de53736dfab57d9db"
+                "4aa8f903a2cba4d46b6c4cc586c32a2a"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/DataIndex.kt",
-                "src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt",
-                "2aa3c6d2dd05057478e723b2247517e1"
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItemProvider.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItemProvider.kt",
+                "4aa8f903a2cba4d46b6c4cc586c32a2a"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/AwaitFirstLayoutModifier.kt",
+                "src/main/java/androidx/tv/foundation/lazy/layout/AwaitFirstLayoutModifier.kt",
+                "blah"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutBeyondBoundsInfo.kt",
+                "src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutBeyondBoundsInfo.kt",
+                "blah"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutNearestRangeState.kt",
+                "src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutNearestRangeState.kt",
+                "blah"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt",
+                "/src/main/java/androidx/tv/foundation/Scroll.kt",
+                "blah"
+        )
+)
+
+copiedClasses.add(
+        new CopiedClass(
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemInfo.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt",
+                "blah"
         )
 )
 
@@ -123,7 +163,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt",
-                "37cb0caf8a170a4161da346806c7a236"
+                "8196242113087ee01151552389745b14"
         )
 )
 
@@ -131,7 +171,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListHeaders.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt",
-                "4d71c69f9cb38f741da9cfc4109567dd"
+                "5b7d3ccdb09f41ec7790991ca313b734"
         )
 )
 
@@ -139,7 +179,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt",
-                "b27d616f6e4758d9e5cfd721cd74f696"
+                "77cfe176fc4cc7b23b6d68a565882ef6"
         )
 )
 
@@ -147,7 +187,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt",
-                "79d9698efce71af7507adb1f1f13d587"
+                "e1be5cb2c26c41e37693fc8ba0c82816"
         )
 )
 
@@ -155,7 +195,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt",
-                "9ad614f60b201360f2c276678674a09d"
+                "50a01ef362751d7636de8cb125c25c24"
         )
 )
 
@@ -163,7 +203,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt",
-                "a209b6cf2bcd8a0aff71488ab28c215f"
+                "2d303e4008b8f691d2fba800f2cff10b"
         )
 )
 
@@ -171,15 +211,15 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt",
-                "5cc9c72197679de004d98b73ffacf038"
+                "f6a82da105a11e3037353ddf71a06bd8"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScopeImpl.kt",
-                "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListScopeImpl.kt",
-                "fab951ddba90c5c5426e4d0104bc9929"
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt",
+                "src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItem.kt",
+                "5cc9c72197679de004d98b73ffacf038"
         )
 )
 
@@ -187,7 +227,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListScrollPosition.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt",
-                "08d08515f25eb3032f6efbf9f86be102"
+                "a3578c4eadde6e9b2d65ac8dda25a1bb"
         )
 )
 
@@ -195,23 +235,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt",
-                "15ed411b8761387c1c0602b68185e312"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItem.kt",
-                "src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt",
-                "a42bc6b7859e14871492ff27ca9bd9a2"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyMeasuredItemProvider.kt",
-                "src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt",
-                "0dcde73635efe26203f70351437cb6fa"
+                "7272cbf1f7ad406ecebbaedff50eea88"
         )
 )
 
@@ -219,15 +243,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListSemantics.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazySemantics.kt",
-                "3a1e86a55ea2282c12745717b5a60cfd"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/ItemIndex.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt",
-                "1031b8b91a81c684b3c4584bc93d3fb0"
+                "f644c8b799080ef0a96cdcbea698603d"
         )
 )
 
@@ -235,7 +251,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridDsl.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt",
-                "6a0b2db56ef38fb1ac004e4fc9847db8"
+                "fbaec87a3693a10268a5bf03638adb03"
         )
 )
 
@@ -243,7 +259,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemInfo.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt",
-                "1f3b13ee45de79bc67ace4133e634600"
+                "b64314b3d025d726c18016c2159fcaf0"
         )
 )
 
@@ -251,7 +267,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt",
-                "bf426a9ae63c2195a88cb382e9e8033e"
+                "7411fe66a22073f328aa307c8db7182d"
         )
 )
 
@@ -259,7 +275,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt",
-                "b3ff4600791c73028b8661c0e2b49110"
+                "9ffacf5b72b88f881c0e85bdd00c6d43"
         )
 )
 
@@ -267,7 +283,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScope.kt",
-                "1a40313cc5e67b5808586c012bbfb058"
+                "62be340f3ab25e3e2c43462c20ff78f9"
         )
 )
 
@@ -275,7 +291,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt",
-                "838dc7602c43c0210a650340110f5f94"
+                "faedb2916edafbf33b179c173a6e7e99"
         )
 )
 
@@ -283,7 +299,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGrid.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt",
-                "c63daa5bd3a004f08fc14a510765b681"
+                "cb67c9a501885e713eab9fc2fbc53ac5"
         )
 )
 
@@ -299,7 +315,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasure.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt",
-                "65b541f64ffc6267ebe7852497a4f37f"
+                "629d06f069b9c74f0dd06d7103f518ab"
         )
 )
 
@@ -313,14 +329,6 @@
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScopeImpl.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt",
-                "e92ebc01a8b205d304e0b0d3c40636f8"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScopeMarker.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeMarker.kt",
                 "0b7ff258a80e2413f89d56ab0ef41b46"
@@ -331,7 +339,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridScrollPosition.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt",
-                "70bac76aeb2617b8f5c706f1867800fd"
+                "adf5c0b390dd6d7080868ddd2c59d749"
         )
 )
 
@@ -347,7 +355,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt",
-                "2a794820a36acc55a13d95fd65d03f45"
+                "b4f4d678cac8199c55d4b4768e5066e3"
         )
 )
 
@@ -355,39 +363,31 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridState.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt",
-                "0e55034861317320888b77f5183b326f"
+                "80892e49be3d89d1bb94f2fab5e8db25"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt",
-                "8bbfd4cdd2d1f090f51ffb0f2d625309"
+                "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredItem.kt",
+                "f22dc4d4899dd7f53d7adeef4dabfd87"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt",
-                "e36c6adfcd6cef885600d62775de0917"
+                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt",
+                "src/main/java/androidx/tv/foundation/lazy/grid/LazyLayoutAnimateItemModifierNode.kt",
+                "blah"
         )
 )
 
 copiedClasses.add(
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredLine.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt",
-                "dedf02d724fb6d470f9566dbf6a260f9"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
-                "src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt",
-                "04949bb943c61f7a18358c3e5543318e"
+                "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredLine.kt",
+                "98e45dc10e1f9cbc649b191dc243bbf7"
         )
 )
 
@@ -395,7 +395,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazySemantics.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazySemantics.kt",
-                "bb397307f2cc3fd87bcc7585bf403039"
+                "1525d43e14f6893ae7ba5290907b6908"
         )
 )
 
@@ -419,7 +419,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScope.kt",
-                "6254294540cfadf2d6da1bbbce1611e8"
+                "1dcc195229055f46fb24983ffca58d4e"
         )
 )
 
@@ -427,7 +427,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemInfo.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt",
-                "7571daa18ca079fd6de31d37c3022574"
+                "c9ca74cfa34be20b61f89bf926a17acf"
         )
 )
 
@@ -435,7 +435,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListLayoutInfo.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/TvLazyListLayoutInfo.kt",
-                "1e2ff3f4fcaa528d1011f32c8a87e100"
+                "060b1374487b4b78c60409fdea1cd75f"
         )
 )
 
@@ -443,7 +443,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemantics.kt",
                 "src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt",
-                "8b9e4a03c5097b4ef7377f98da95bbcd"
+                "d43923910ef811c75522ad36c0c9e700"
         )
 )
 
@@ -451,7 +451,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyAnimateScroll.kt",
                 "src/main/java/androidx/tv/foundation/lazy/layout/LazyAnimateScroll.kt",
-                "f9d4a924665f65ac319b6071358431b9"
+                "9a0f877f05d2e5a69c7b6a1eb8ef2b73"
         )
 )
 
@@ -459,7 +459,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridAnimateScrollScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt",
-                "d07400716d6139405135ffbfe042b762"
+                "dfedfe292e3bfe5c3d0bd4d63856a650"
         )
 )
 
@@ -467,23 +467,7 @@
         new CopiedClass(
                 "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListAnimateScrollScope.kt",
                 "src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt",
-                "c769969ce9a74ee6006d1c0b76b47095"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "../../compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/ContentInViewModifier.kt",
-                "src/main/java/androidx/tv/foundation/ContentInViewModifier.kt",
-                "6dec263110d0fe60021cf6fb9c93bd90"
-        )
-)
-
-copiedClasses.add(
-        new CopiedClass(
-                "compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutSemanticState.kt",
-                "src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt",
-                "ad"
+                "b79f7582feddf8fce22bfaf9ac5178d8"
         )
 )
 
diff --git a/tv/tv-foundation/lint-baseline.xml b/tv/tv-foundation/lint-baseline.xml
index 84c2667..ce0447e 100644
--- a/tv/tv-foundation/lint-baseline.xml
+++ b/tv/tv-foundation/lint-baseline.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<issues format="6" by="lint 8.1.0-beta02" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.0-beta02)" variant="all" version="8.1.0-beta02">
+<issues format="6" by="lint 8.1.0-beta05" type="baseline" client="gradle" dependencies="false" name="AGP (8.1.0-beta05)" variant="all" version="8.1.0-beta05">
 
     <issue
         id="BanThreadSleep"
@@ -38,15 +38,6 @@
     </issue>
 
     <issue
-        id="ExceptionMessage"
-        message="Please specify a lazyMessage param for requireNotNull"
-        errorLine1="                            val keyFactory = requireNotNull(it.value.key)"
-        errorLine2="                                             ~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt"/>
-    </issue>
-
-    <issue
         id="PrimitiveInLambda"
         message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method items has parameter &apos;key&apos; with type Function1&lt;? super Integer, ? extends Object>."
         errorLine1="        key: ((index: Int) -> Any)? = null,"
@@ -93,9 +84,9 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method LazyGrid has parameter &apos;slotSizesSums&apos; with type Function2&lt;? super Density, ? super Constraints, ? extends List&lt;Integer>>."
-        errorLine1="    slotSizesSums: Density.(Constraints) -> List&lt;Int>,"
-        errorLine2="                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method LazyGrid has parameter &apos;slots&apos; with type Function2&lt;? super Density, ? super Constraints, LazyGridSlots>."
+        errorLine1="    slots: Density.(Constraints) -> LazyGridSlots,"
+        errorLine2="           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt"/>
     </issue>
@@ -120,9 +111,9 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method rememberLazyGridMeasurePolicy has parameter &apos;slotSizesSums&apos; with type Function2&lt;? super Density, ? super Constraints, ? extends List&lt;Integer>>."
-        errorLine1="    slotSizesSums: Density.(Constraints) -> List&lt;Int>,"
-        errorLine2="                   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method rememberLazyGridMeasurePolicy has parameter &apos;slots&apos; with type Function2&lt;? super Density, ? super Constraints, LazyGridSlots>."
+        errorLine1="    slots: Density.(Constraints) -> LazyGridSlots,"
+        errorLine2="           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt"/>
     </issue>
@@ -138,25 +129,7 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;slotSizesSums&apos; with type Function2&lt;? super Density, ? super Constraints, ? extends List&lt;? extends Integer>>."
-        errorLine1="    val slotSizesSums = rememberColumnWidthSums(columns, horizontalArrangement, contentPadding)"
-        errorLine2="    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;slotSizesSums&apos; with type Function2&lt;? super Density, ? super Constraints, ? extends List&lt;? extends Integer>>."
-        errorLine1="    val slotSizesSums = rememberRowHeightSums(rows, verticalArrangement, contentPadding)"
-        errorLine2="    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;Density, Constraints, List&lt;Integer>> of &apos;rememberColumnWidthSums&apos;."
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;Density, Constraints, LazyGridSlots> of &apos;rememberColumnWidthSums&apos;."
         errorLine1="private fun rememberColumnWidthSums("
         errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~">
         <location
@@ -165,7 +138,7 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;Density, Constraints, List&lt;Integer>> of &apos;rememberRowHeightSums&apos;."
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;Density, Constraints, LazyGridSlots> of &apos;rememberRowHeightSums&apos;."
         errorLine1="private fun rememberRowHeightSums("
         errorLine2="            ~~~~~~~~~~~~~~~~~~~~~">
         <location
@@ -174,6 +147,15 @@
 
     <issue
         id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor GridSlotCache has parameter &apos;calculation&apos; with type Function2&lt;? super Density, ? super Constraints, LazyGridSlots>."
+        errorLine1="    private val calculation: Density.(Constraints) -> LazyGridSlots"
+        errorLine2="                             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
         message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method item has parameter &apos;span&apos; with type Function1&lt;? super TvLazyGridItemSpanScope, TvGridItemSpan>."
         errorLine1="        span: (TvLazyGridItemSpanScope.() -> TvGridItemSpan)? = null,"
         errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -291,36 +273,90 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor NearestRangeKeyIndexMapState has parameter &apos;firstVisibleItemIndex&apos; with type Function0&lt;Integer>."
-        errorLine1="    firstVisibleItemIndex: () -> Int,"
-        errorLine2="                           ~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;var72d50710&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, ? extends TvGridItemSpan>."
+        errorLine1="                span = span?.let { { span() } } ?: DefaultSpan,"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
-            file="src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt"/>
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
     </issue>
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor NearestRangeKeyIndexMapState has parameter &apos;slidingWindowSize&apos; with type Function0&lt;Integer>."
-        errorLine1="    slidingWindowSize: () -> Int,"
-        errorLine2="                       ~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;vara1157cff&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, ? extends TvGridItemSpan>."
+        errorLine1="                span = span ?: DefaultSpan,"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~">
         <location
-            file="src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt"/>
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
     </issue>
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor NearestRangeKeyIndexMapState has parameter &apos;extraItemCount&apos; with type Function0&lt;Integer>."
-        errorLine1="    extraItemCount: () -> Int,"
-        errorLine2="                    ~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemSpanScope, Integer, TvGridItemSpan> of &apos;getDefaultSpan&apos;."
+        errorLine1="        val DefaultSpan: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan ="
+        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
-            file="src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt"/>
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;key&apos; with type Function1&lt;? super Integer, ? extends Object>."
+        errorLine1="    override val key: ((index: Int) -> Any)?,"
+        errorLine2="                      ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;span&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, TvGridItemSpan>."
+        errorLine1="    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,"
+        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemSpanScope, Integer, TvGridItemSpan> of &apos;getSpan&apos;."
+        errorLine1="    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,"
+        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;type&apos; with type Function1&lt;? super Integer, ? extends Object>."
+        errorLine1="    override val type: ((index: Int) -> Any?),"
+        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;item&apos; with type Function2&lt;? super TvLazyGridItemScope, ? super Integer, Unit>."
+        errorLine1="    val item: @Composable TvLazyGridItemScope.(Int) -> Unit"
+        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
+    </issue>
+
+    <issue
+        id="PrimitiveInLambda"
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemScope, Integer, Unit> of &apos;getItem&apos;."
+        errorLine1="    val item: @Composable TvLazyGridItemScope.(Int) -> Unit"
+        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt"/>
     </issue>
 
     <issue
         id="PrimitiveInLambda"
         message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;keyFactory&apos; with type Function1&lt;? super Integer, ? extends Object>."
-        errorLine1="                            val keyFactory = requireNotNull(it.value.key)"
-        errorLine2="                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        errorLine1="                    val keyFactory = it.value.key"
+        errorLine2="                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt"/>
     </issue>
@@ -372,51 +408,6 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method measureLazyList has parameter &apos;layout&apos; with type Function3&lt;? super Integer, ? super Integer, ? super Function1&lt;? super PlacementScope, Unit>, ? extends MeasureResult>."
-        errorLine1="    layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult"
-        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;endIndex&apos; with type Function1&lt;? super LazyListBeyondBoundsInfo, ? extends Integer>."
-        errorLine1="    fun LazyListBeyondBoundsInfo.endIndex() = min(end, itemsCount - 1)"
-        errorLine2="    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;addItem&apos; with type Function1&lt;? super Integer, ? extends Unit>."
-        errorLine1="    fun addItem(index: Int) {"
-        errorLine2="    ^">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;startIndex&apos; with type Function1&lt;? super LazyListBeyondBoundsInfo, ? extends Integer>."
-        errorLine1="    fun LazyListBeyondBoundsInfo.startIndex() = min(start, itemsCount - 1)"
-        errorLine2="    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;addItem&apos; with type Function1&lt;? super Integer, ? extends Unit>."
-        errorLine1="    fun addItem(index: Int) {"
-        errorLine2="    ^">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
         message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;reverseAware&apos; with type Function1&lt;? super Integer, ? extends Integer>."
         errorLine1="        fun Int.reverseAware() ="
         errorLine2="        ^">
@@ -426,88 +417,7 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;var9cfa9825&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, ? extends TvGridItemSpan>."
-        errorLine1="                span = span?.let { { span() } } ?: DefaultSpan,"
-        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in variable &apos;varcb3b0c51&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, ? extends TvGridItemSpan>."
-        errorLine1="                span = span ?: DefaultSpan,"
-        errorLine2="                       ~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemSpanScope, Integer, TvGridItemSpan> of &apos;getDefaultSpan&apos;."
-        errorLine1="        val DefaultSpan: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan = { TvGridItemSpan(1) }"
-        errorLine2="                         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;key&apos; with type Function1&lt;? super Integer, ? extends Object>."
-        errorLine1="    override val key: ((index: Int) -> Any)?,"
-        errorLine2="                      ~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;span&apos; with type Function2&lt;? super TvLazyGridItemSpanScope, ? super Integer, TvGridItemSpan>."
-        errorLine1="    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,"
-        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemSpanScope, Integer, TvGridItemSpan> of &apos;getSpan&apos;."
-        errorLine1="    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,"
-        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;type&apos; with type Function1&lt;? super Integer, ? extends Object>."
-        errorLine1="    override val type: ((index: Int) -> Any?),"
-        errorLine2="                       ~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in constructor LazyGridInterval has parameter &apos;item&apos; with type Function2&lt;? super TvLazyGridItemScope, ? super Integer, Unit>."
-        errorLine1="    val item: @Composable TvLazyGridItemScope.(Int) -> Unit"
-        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function2&lt;TvLazyGridItemScope, Integer, Unit> of &apos;getItem&apos;."
-        errorLine1="    val item: @Composable TvLazyGridItemScope.(Int) -> Unit"
-        errorLine2="              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
-        <location
-            file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt"/>
-    </issue>
-
-    <issue
-        id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method setPrefetchInfoRetriever$lint_module has parameter &apos;&lt;set-?>&apos; with type Function1&lt;? super LineIndex, ? extends List&lt;Pair&lt;Integer, Constraints>>>."
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in method setPrefetchInfoRetriever$lint_module has parameter &apos;&lt;set-?>&apos; with type Function1&lt;? super Integer, ? extends List&lt;Pair&lt;Integer, Constraints>>>."
         errorLine1="    /**"
         errorLine2="    ^">
         <location
@@ -516,9 +426,9 @@
 
     <issue
         id="PrimitiveInLambda"
-        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function1&lt;LineIndex, List&lt;Pair&lt;Integer, Constraints>>> of &apos;getPrefetchInfoRetriever$lint_module&apos;."
-        errorLine1="    internal var prefetchInfoRetriever: (line: LineIndex) -> List&lt;Pair&lt;Int, Constraints>> by"
-        errorLine2="                                        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        message="Use a functional interface instead of lambda syntax for lambdas with primitive values in return type Function1&lt;Integer, List&lt;Pair&lt;Integer, Constraints>>> of &apos;getPrefetchInfoRetriever$lint_module&apos;."
+        errorLine1="    internal var prefetchInfoRetriever: (line: Int) -> List&lt;Pair&lt;Int, Constraints>> by"
+        errorLine2="                                        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt"/>
     </issue>
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt
index a87c1a9..e828a8e 100644
--- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateItemPlacementTest.kt
@@ -18,6 +18,10 @@
 
 import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.scrollBy
@@ -53,10 +57,10 @@
 import androidx.test.filters.LargeTest
 import com.google.common.truth.Truth.assertThat
 import com.google.common.truth.Truth.assertWithMessage
+import java.util.concurrent.TimeUnit
 import kotlin.math.roundToInt
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -73,15 +77,17 @@
     @get:Rule
     val rule = createComposeRule()
 
-    private val itemSize: Float = 50f
+    // the numbers should be divisible by 8 to avoid the rounding issues as we run 4 or 8 frames
+    // of the animation.
+    private val itemSize: Float = 40f
     private var itemSizeDp: Dp = Dp.Infinity
-    private val itemSize2: Float = 30f
+    private val itemSize2: Float = 24f
     private var itemSize2Dp: Dp = Dp.Infinity
-    private val itemSize3: Float = 20f
+    private val itemSize3: Float = 16f
     private var itemSize3Dp: Dp = Dp.Infinity
     private val containerSize: Float = itemSize * 5
     private var containerSizeDp: Dp = Dp.Infinity
-    private val spacing: Float = 10f
+    private val spacing: Float = 8f
     private var spacingDp: Dp = Dp.Infinity
     private val itemSizePlusSpacing = itemSize + spacing
     private var itemSizePlusSpacingDp = Dp.Infinity
@@ -1236,7 +1242,6 @@
         }
     }
 
-    @Ignore("b/283960548")
     @Test
     fun noAnimationWhenScrollForwardByLargeOffset_differentSizes() {
         rule.setContent {
@@ -2087,6 +2092,285 @@
         }
     }
 
+    @Test
+    fun scrollIsAffectingItemsMovingWithinViewport() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3))
+        val scrollDelta = spacing
+        rule.setContent {
+            LazyGrid(1, maxSize = itemSizeDp * 2) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(0, 2, 1, 3)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, itemSize),
+                    2 to AxisOffset(0f, itemSize * 2),
+                    fraction = fraction
+                )
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(scrollDelta) }
+                }
+            }
+            assertPositions(
+                0 to AxisOffset(0f, -scrollDelta),
+                1 to AxisOffset(0f, itemSize - scrollDelta + itemSize * fraction),
+                2 to AxisOffset(0f, itemSize * 2 - scrollDelta - itemSize * fraction),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun scrollIsNotAffectingItemMovingToTheBottomOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val scrollDelta = spacing
+        val containerSizeDp = itemSizeDp * 2
+        val containerSize = itemSize * 2
+        rule.setContent {
+            LazyGrid(1, maxSize = containerSizeDp) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(0, 4, 2, 3, 1)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, itemSize),
+                    fraction = fraction
+                )
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(scrollDelta) }
+                }
+            }
+            assertPositions(
+                0 to AxisOffset(0f, -scrollDelta),
+                1 to AxisOffset(0f, itemSize + (containerSize - itemSize) * fraction),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun scrollIsNotAffectingItemMovingToTheTopOutsideOfBounds() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val scrollDelta = -spacing
+        val containerSizeDp = itemSizeDp * 2
+        rule.setContent {
+            LazyGrid(1, maxSize = containerSizeDp, startIndex = 2) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(3, 0, 1, 2, 4)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    2 to AxisOffset(0f, 0f),
+                    3 to AxisOffset(0f, itemSize),
+                    fraction = fraction
+                )
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(scrollDelta) }
+                }
+            }
+            assertPositions(
+                2 to AxisOffset(0f, -scrollDelta),
+                3 to AxisOffset(0f, itemSize - (itemSize * 2 * fraction)),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun afterScrollingEnoughToReachNewPositionScrollDeltasStartAffectingPosition() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val containerSizeDp = itemSizeDp * 2
+        val scrollDelta = spacing
+        rule.setContent {
+            LazyGrid(1, maxSize = containerSizeDp) {
+                items(list, key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(0, 4, 2, 3, 1)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, itemSize),
+                    fraction = fraction
+                )
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(itemSize * 2) }
+                }
+                assertPositions(
+                    2 to AxisOffset(0f, 0f),
+                    3 to AxisOffset(0f, itemSize),
+                    // after the first scroll the new position of item 1 is still not reached
+                    // so the target didn't change, we still aim to end right after the bounds
+                    1 to AxisOffset(0f, itemSize),
+                    fraction = fraction
+                )
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(scrollDelta) }
+                }
+                assertPositions(
+                    2 to AxisOffset(0f, 0f - scrollDelta),
+                    3 to AxisOffset(0f, itemSize - scrollDelta),
+                    // after the second scroll the item 1 is visible, so we know its new target
+                    // position. the animation is now targeting the real end position and now
+                    // we are reacting on the scroll deltas
+                    1 to AxisOffset(0f, itemSize - scrollDelta),
+                    fraction = fraction
+                )
+            }
+            assertPositions(
+                2 to AxisOffset(0f, -scrollDelta),
+                3 to AxisOffset(0f, itemSize - scrollDelta),
+                1 to AxisOffset(0f, itemSize - scrollDelta + itemSize * fraction),
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
+    fun interruptedSizeChange() {
+        var item0Size by mutableStateOf(itemSizeDp)
+        val animSpec = spring(visibilityThreshold = IntOffset.VisibilityThreshold)
+        rule.setContent {
+            LazyGrid(cells = 1) {
+                items(2, key = { it }) {
+                    Item(it, if (it == 0) item0Size else itemSizeDp, animSpec = animSpec)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            item0Size = itemSize2Dp
+        }
+
+        rule.waitForIdle()
+        rule.mainClock.advanceTimeByFrame()
+        onAnimationFrame(duration = FrameDuration) { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, itemSize)
+                )
+            } else {
+                assertThat(fraction).isEqualTo(1f)
+                val valueAfterOneFrame =
+                    animSpec.getValueAtFrame(1, from = itemSize, to = itemSize2)
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, valueAfterOneFrame)
+                )
+            }
+        }
+
+        rule.runOnUiThread {
+            item0Size = 0.dp
+        }
+
+        rule.waitForIdle()
+        val startValue = animSpec.getValueAtFrame(2, from = itemSize, to = itemSize2)
+        val startVelocity = animSpec.getVelocityAtFrame(2, from = itemSize, to = itemSize2)
+        onAnimationFrame(duration = FrameDuration) { fraction ->
+            if (fraction == 0f) {
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, startValue)
+                )
+            } else {
+                assertThat(fraction).isEqualTo(1f)
+                val valueAfterThreeFrames = animSpec.getValueAtFrame(
+                    1,
+                    from = startValue,
+                    to = 0f,
+                    initialVelocity = startVelocity
+                )
+                assertPositions(
+                    0 to AxisOffset(0f, 0f),
+                    1 to AxisOffset(0f, valueAfterThreeFrames)
+                )
+            }
+        }
+    }
+
+    internal fun SpringSpec<IntOffset>.getVelocityAtFrame(
+        frameCount: Int,
+        from: Float,
+        to: Float,
+        initialVelocity: IntOffset = IntOffset.Zero
+    ): IntOffset {
+        val frameInNanos = TimeUnit.MILLISECONDS.toNanos(FrameDuration)
+        val vectorized = vectorize(converter = IntOffset.VectorConverter)
+        return IntOffset.VectorConverter.convertFromVector(
+            vectorized.getVelocityFromNanos(
+                playTimeNanos = frameInNanos * frameCount,
+                initialValue = IntOffset.VectorConverter.convertToVector(
+                    IntOffset(0, from.toInt())
+                ),
+                targetValue = IntOffset.VectorConverter.convertToVector(
+                    IntOffset(0, to.toInt())
+                ),
+                initialVelocity = IntOffset.VectorConverter.convertToVector(
+                    initialVelocity
+                )
+            )
+        )
+    }
+
+    internal fun SpringSpec<IntOffset>.getValueAtFrame(
+        frameCount: Int,
+        from: Float,
+        to: Float,
+        initialVelocity: IntOffset = IntOffset.Zero
+    ): Float {
+        val frameInNanos = TimeUnit.MILLISECONDS.toNanos(FrameDuration)
+        val vectorized = vectorize(converter = IntOffset.VectorConverter)
+        return IntOffset.VectorConverter.convertFromVector(
+            vectorized.getValueFromNanos(
+                playTimeNanos = frameInNanos * frameCount,
+                initialValue = IntOffset.VectorConverter.convertToVector(
+                    IntOffset(0, from.toInt())
+                ),
+                targetValue = IntOffset.VectorConverter.convertToVector(
+                    IntOffset(0, to.toInt())
+                ),
+                initialVelocity = IntOffset.VectorConverter.convertToVector(
+                    initialVelocity
+                )
+            )
+        ).y.toFloat()
+    }
+
     private fun AxisOffset(crossAxis: Float, mainAxis: Float) =
         if (isVertical) Offset(crossAxis, mainAxis) else Offset(mainAxis, crossAxis)
 
@@ -2170,9 +2454,11 @@
         for (i in 0..duration step FrameDuration) {
             val fraction = i / duration.toFloat()
             onFrame(fraction)
-            rule.mainClock.advanceTimeBy(FrameDuration)
-            expectedTime += FrameDuration
-            assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            if (i < duration) {
+                rule.mainClock.advanceTimeBy(FrameDuration)
+                expectedTime += FrameDuration
+                assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            }
         }
     }
 
@@ -2226,7 +2512,11 @@
         animSpec: FiniteAnimationSpec<IntOffset>? = AnimSpec
     ) {
         Box(
-            Modifier
+            if (animSpec != null) {
+                Modifier.animateItemPlacement(animSpec)
+            } else {
+                Modifier
+            }
                 .then(
                     if (isVertical) {
                         Modifier.requiredHeight(size)
@@ -2235,13 +2525,6 @@
                     }
                 )
                 .testTag(tag.toString())
-                .then(
-                    if (animSpec != null) {
-                        Modifier.animateItemPlacement(animSpec)
-                    } else {
-                        Modifier
-                    }
-                )
         )
     }
 
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridTest.kt
index 618df1e..13b3fde 100644
--- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridTest.kt
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/grid/LazyGridTest.kt
@@ -1100,5 +1100,6 @@
 internal fun ComposeContentTestRule.keyPress(keyCode: Int, numberOfPresses: Int = 1) {
     repeat(numberOfPresses) {
         InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(keyCode)
+        waitForIdle()
     }
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
index 86f2902..1857244 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/ScrollableWithPivot.kt
@@ -20,7 +20,7 @@
 import androidx.compose.animation.core.CubicBezierEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.BringIntoViewScroller
+import androidx.compose.foundation.gestures.BringIntoViewSpec
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.ScrollableState
 import androidx.compose.foundation.gestures.scrollable
@@ -73,15 +73,15 @@
         enabled = enabled,
         reverseDirection = reverseDirection,
         overscrollEffect = null,
-        bringIntoViewScroller = TvBringIntoViewScroller(pivotOffsets, enabled)
+        bringIntoViewSpec = TvBringIntoViewSpec(pivotOffsets, enabled)
     )
 }
 
 @OptIn(ExperimentalFoundationApi::class)
-private class TvBringIntoViewScroller(
+private class TvBringIntoViewSpec(
     val pivotOffsets: PivotOffsets,
     val userScrollEnabled: Boolean
-) : BringIntoViewScroller {
+) : BringIntoViewSpec {
 
     override val scrollAnimationSpec: AnimationSpec<Float> = tween<Float>(
         durationMillis = 125,
@@ -118,7 +118,7 @@
     }
 
     override fun equals(other: Any?): Boolean {
-        if (other !is TvBringIntoViewScroller) return false
+        if (other !is TvBringIntoViewSpec) return false
         return pivotOffsets == other.pivotOffsets && userScrollEnabled == other.userScrollEnabled
     }
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt
deleted file mode 100644
index 8ae2a25..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/ItemIndex.kt
+++ /dev/null
@@ -1,45 +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.
- */
-
-package androidx.tv.foundation.lazy.grid
-
-/**
- * Represents a line index in the lazy grid.
- */
-@Suppress("NOTHING_TO_INLINE")
-@kotlin.jvm.JvmInline
-internal value class LineIndex(val value: Int) {
-    inline operator fun inc(): LineIndex = LineIndex(value + 1)
-    inline operator fun dec(): LineIndex = LineIndex(value - 1)
-    inline operator fun plus(i: Int): LineIndex = LineIndex(value + i)
-    inline operator fun minus(i: Int): LineIndex = LineIndex(value - i)
-    inline operator fun minus(i: LineIndex): LineIndex = LineIndex(value - i.value)
-    inline operator fun compareTo(other: LineIndex): Int = value - other.value
-}
-
-/**
- * Represents an item index in the lazy grid.
- */
-@Suppress("NOTHING_TO_INLINE")
-@kotlin.jvm.JvmInline
-internal value class ItemIndex(val value: Int) {
-    inline operator fun inc(): ItemIndex = ItemIndex(value + 1)
-    inline operator fun dec(): ItemIndex = ItemIndex(value - 1)
-    inline operator fun plus(i: Int): ItemIndex = ItemIndex(value + i)
-    inline operator fun minus(i: Int): ItemIndex = ItemIndex(value - i)
-    inline operator fun minus(i: ItemIndex): ItemIndex = ItemIndex(value - i.value)
-    inline operator fun compareTo(other: ItemIndex): Int = value - other.value
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
index 5cf5964..b40f49c 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGrid.kt
@@ -29,10 +29,10 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
@@ -45,6 +45,9 @@
 import androidx.tv.foundation.ExperimentalTvFoundationApi
 import androidx.tv.foundation.PivotOffsets
 import androidx.tv.foundation.lazy.layout.lazyLayoutSemantics
+import androidx.tv.foundation.lazy.list.LazyLayoutBeyondBoundsModifierLocal
+import androidx.tv.foundation.lazy.list.LazyLayoutBeyondBoundsState
+import androidx.tv.foundation.lazy.list.calculateLazyLayoutPinnedIndices
 import androidx.tv.foundation.scrollableWithPivot
 
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
@@ -56,7 +59,7 @@
     /** State controlling the scroll position */
     state: TvLazyGridState,
     /** Prefix sums of cross axis sizes of slots per line, e.g. the columns for vertical grid. */
-    slotSizesSums: Density.(Constraints) -> List<Int>,
+    slots: Density.(Constraints) -> LazyGridSlots,
     /** The inner padding to be added for the whole content (not for each individual item) */
     contentPadding: PaddingValues = PaddingValues(0.dp),
     /** reverse the direction of scrolling and layout */
@@ -75,31 +78,24 @@
     /** The content of the grid */
     content: TvLazyGridScope.() -> Unit
 ) {
-    val itemProvider = rememberLazyGridItemProvider(state, content)
+    val itemProviderLambda = rememberLazyGridItemProviderLambda(state, content)
 
     val semanticState = rememberLazyGridSemanticState(state, reverseLayout)
 
-    val scope = rememberCoroutineScope()
-    val placementAnimator = remember(state, isVertical) {
-        LazyGridItemPlacementAnimator(scope, isVertical)
-    }
-    state.placementAnimator = placementAnimator
-
     val measurePolicy = rememberLazyGridMeasurePolicy(
-        itemProvider,
+        itemProviderLambda,
         state,
-        slotSizesSums,
+        slots,
         contentPadding,
         reverseLayout,
         isVertical,
         horizontalArrangement,
         verticalArrangement,
-        placementAnimator
     )
 
     state.isVertical = isVertical
 
-    ScrollPositionUpdater(itemProvider, state)
+    ScrollPositionUpdater(itemProviderLambda, state)
 
     val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
     LazyLayout(
@@ -107,13 +103,18 @@
             .then(state.remeasurementModifier)
             .then(state.awaitLayoutModifier)
             .lazyLayoutSemantics(
-                itemProvider = itemProvider,
+                itemProviderLambda = itemProviderLambda,
                 state = semanticState,
                 orientation = orientation,
                 userScrollEnabled = userScrollEnabled,
                 reverseScrolling = reverseLayout
             )
             .clipScrollableContainer(orientation)
+            .lazyGridBeyondBoundsModifier(
+                state,
+                reverseLayout,
+                orientation
+            )
             .scrollableWithPivot(
                 orientation = orientation,
                 reverseDirection = ScrollableDefaults.reverseDirection(
@@ -127,7 +128,7 @@
             ),
         prefetchState = state.prefetchState,
         measurePolicy = measurePolicy,
-        itemProvider = { itemProvider }
+        itemProvider = itemProviderLambda
     )
 }
 
@@ -135,23 +136,30 @@
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun ScrollPositionUpdater(
-    itemProvider: LazyGridItemProvider,
+    itemProviderLambda: () -> LazyGridItemProvider,
     state: TvLazyGridState
 ) {
+    val itemProvider = itemProviderLambda()
     if (itemProvider.itemCount > 0) {
         state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
     }
 }
 
+/** lazy grid slots configuration */
+internal class LazyGridSlots(
+    val sizes: IntArray,
+    val positions: IntArray
+)
+
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun rememberLazyGridMeasurePolicy(
     /** Items provider of the list. */
-    itemProvider: LazyGridItemProvider,
+    itemProviderLambda: () -> LazyGridItemProvider,
     /** The state of the list. */
     state: TvLazyGridState,
     /** Prefix sums of cross axis sizes of slots of the grid. */
-    slotSizesSums: Density.(Constraints) -> List<Int>,
+    slots: Density.(Constraints) -> LazyGridSlots,
     /** The inner padding to be added for the whole content(nor for each individual item) */
     contentPadding: PaddingValues,
     /** reverse the direction of scrolling and layout */
@@ -162,17 +170,14 @@
     horizontalArrangement: Arrangement.Horizontal? = null,
     /** The vertical arrangement for items. Required when isVertical is true */
     verticalArrangement: Arrangement.Vertical? = null,
-    /** Item placement animator. Should be notified with the measuring result */
-    placementAnimator: LazyGridItemPlacementAnimator
 ) = remember<LazyLayoutMeasureScope.(Constraints) -> MeasureResult>(
     state,
-    slotSizesSums,
+    slots,
     contentPadding,
     reverseLayout,
     isVertical,
     horizontalArrangement,
-    verticalArrangement,
-    placementAnimator
+    verticalArrangement
 ) {
     { containerConstraints ->
         checkScrollableContainerConstraints(
@@ -211,33 +216,27 @@
         val contentConstraints =
             containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding)
 
-        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
+        val itemProvider = itemProviderLambda()
 
         val spanLayoutProvider = itemProvider.spanLayoutProvider
-        val resolvedSlotSizesSums = slotSizesSums(containerConstraints)
-        spanLayoutProvider.slotsPerLine = resolvedSlotSizesSums.size
+        val resolvedSlots = slots(containerConstraints)
+        val slotsPerLine = resolvedSlots.sizes.size
+        spanLayoutProvider.slotsPerLine = slotsPerLine
 
         // Update the state's cached Density and slotsPerLine
         state.density = this
-        state.slotsPerLine = resolvedSlotSizesSums.size
+        state.slotsPerLine = slotsPerLine
 
         val spaceBetweenLinesDp = if (isVertical) {
             requireNotNull(verticalArrangement) {
-                "encountered null verticalArrangement when isVertical == true"
+                "null verticalArrangement when isVertical == true"
             }.spacing
         } else {
             requireNotNull(horizontalArrangement) {
-                "encountered null horizontalArrangement when isVertical == false"
+                "null horizontalArrangement when isVertical == false"
             }.spacing
         }
         val spaceBetweenLines = spaceBetweenLinesDp.roundToPx()
-        val spaceBetweenSlotsDp = if (isVertical) {
-            horizontalArrangement?.spacing ?: 0.dp
-        } else {
-            verticalArrangement?.spacing ?: 0.dp
-        }
-        val spaceBetweenSlots = spaceBetweenSlotsDp.roundToPx()
-
         val itemsCount = itemProvider.itemCount
 
         // can be negative if the content padding is larger than the max size from constraints
@@ -258,12 +257,19 @@
             )
         }
 
-        val measuredItemProvider = LazyMeasuredItemProvider(
+        val measuredItemProvider = object : LazyGridMeasuredItemProvider(
             itemProvider,
             this,
             spaceBetweenLines
-        ) { index, key, crossAxisSize, mainAxisSpacing, placeables ->
-            LazyMeasuredItem(
+        ) {
+            override fun createItem(
+                index: Int,
+                key: Any,
+                contentType: Any?,
+                crossAxisSize: Int,
+                mainAxisSpacing: Int,
+                placeables: List<Placeable>
+            ) = LazyGridMeasuredItem(
                 index = index,
                 key = key,
                 isVertical = isVertical,
@@ -275,50 +281,53 @@
                 afterContentPadding = afterContentPadding,
                 visualOffset = visualItemOffset,
                 placeables = placeables,
-                placementAnimator = placementAnimator
+                contentType = contentType
             )
         }
-        val measuredLineProvider = LazyMeasuredLineProvider(
-            isVertical,
-            resolvedSlotSizesSums,
-            spaceBetweenSlots,
-            itemsCount,
-            spaceBetweenLines,
-            measuredItemProvider,
-            spanLayoutProvider
-        ) { index, items, spans, mainAxisSpacing ->
-            LazyMeasuredLine(
+        val measuredLineProvider = object : LazyGridMeasuredLineProvider(
+            isVertical = isVertical,
+            slots = resolvedSlots,
+            gridItemsCount = itemsCount,
+            spaceBetweenLines = spaceBetweenLines,
+            measuredItemProvider = measuredItemProvider,
+            spanLayoutProvider = spanLayoutProvider
+        ) {
+            override fun createLine(
+                index: Int,
+                items: Array<LazyGridMeasuredItem>,
+                spans: List<TvGridItemSpan>,
+                mainAxisSpacing: Int
+            ) = LazyGridMeasuredLine(
                 index = index,
                 items = items,
                 spans = spans,
+                slots = resolvedSlots,
                 isVertical = isVertical,
-                slotsPerLine = resolvedSlotSizesSums.size,
-                layoutDirection = layoutDirection,
                 mainAxisSpacing = mainAxisSpacing,
-                crossAxisSpacing = spaceBetweenSlots
             )
         }
         state.prefetchInfoRetriever = { line ->
-            val lineConfiguration = spanLayoutProvider.getLineConfiguration(line.value)
-            var index = ItemIndex(lineConfiguration.firstItemIndex)
+            val lineConfiguration = spanLayoutProvider.getLineConfiguration(line)
+            var index = lineConfiguration.firstItemIndex
             var slot = 0
             val result = ArrayList<Pair<Int, Constraints>>(lineConfiguration.spans.size)
             lineConfiguration.spans.fastForEach {
                 val span = it.currentLineSpan
-                result.add(index.value to measuredLineProvider.childConstraints(slot, span))
+                result.add(index to measuredLineProvider.childConstraints(slot, span))
                 ++index
                 slot += span
             }
             result
         }
 
-        val firstVisibleLineIndex: LineIndex
+        val firstVisibleLineIndex: Int
         val firstVisibleLineScrollOffset: Int
         Snapshot.withoutReadObservation {
-            if (state.firstVisibleItemIndex < itemsCount || itemsCount <= 0) {
-                firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem(
-                    state.firstVisibleItemIndex
-                )
+            val index = state.updateScrollPositionIfTheFirstItemWasMoved(
+                itemProvider, state.firstVisibleItemIndex
+            )
+            if (index < itemsCount || itemsCount <= 0) {
+                firstVisibleLineIndex = spanLayoutProvider.getLineIndexOfItem(index)
                 firstVisibleLineScrollOffset = state.firstVisibleItemScrollOffset
             } else {
                 // the data set has been updated and now we have less items that we were
@@ -327,9 +336,14 @@
                 firstVisibleLineScrollOffset = 0
             }
         }
+
+        val pinnedItems = itemProvider.calculateLazyLayoutPinnedIndices(
+            state.pinnedItems,
+            state.beyondBoundsInfo
+        )
+
         measureLazyGrid(
             itemsCount = itemsCount,
-            itemProvider = itemProvider,
             measuredLineProvider = measuredLineProvider,
             measuredItemProvider = measuredItemProvider,
             mainAxisAvailableSize = mainAxisAvailableSize,
@@ -345,9 +359,9 @@
             horizontalArrangement = horizontalArrangement,
             reverseLayout = reverseLayout,
             density = this,
-            placementAnimator = placementAnimator,
+            placementAnimator = state.placementAnimator,
             spanLayoutProvider = spanLayoutProvider,
-            pinnedItems = state.pinnedItems,
+            pinnedItems = pinnedItems,
             layout = { width, height, placement ->
                 layout(
                     containerConstraints.constrainWidth(width + totalHorizontalPadding),
@@ -359,3 +373,53 @@
         ).also { state.applyMeasureResult(it) }
     }
 }
+
+/**
+ * This modifier is used to measure and place additional items when the lazyList receives a
+ * request to layout items beyond the visible bounds.
+ */
+@Suppress("ComposableModifierFactory")
+@Composable
+internal fun Modifier.lazyGridBeyondBoundsModifier(
+    state: TvLazyGridState,
+    reverseLayout: Boolean,
+    orientation: Orientation
+): Modifier {
+    val layoutDirection = LocalLayoutDirection.current
+    val beyondBoundsState = remember(state) {
+        LazyGridBeyondBoundsState(state)
+    }
+    return this then remember(
+        state,
+        beyondBoundsState,
+        reverseLayout,
+        layoutDirection,
+        orientation
+    ) {
+        LazyLayoutBeyondBoundsModifierLocal(
+            beyondBoundsState,
+            state.beyondBoundsInfo,
+            reverseLayout,
+            layoutDirection,
+            orientation
+        )
+    }
+}
+
+internal class LazyGridBeyondBoundsState(
+    val state: TvLazyGridState,
+) : LazyLayoutBeyondBoundsState {
+
+    override fun remeasure() {
+        state.remeasurement?.forceRemeasure()
+    }
+
+    override val itemCount: Int
+        get() = state.layoutInfo.totalItemsCount
+    override val hasVisibleItems: Boolean
+        get() = state.layoutInfo.visibleItemsInfo.isNotEmpty()
+    override val firstPlacedIndex: Int
+        get() = state.firstVisibleItemIndex
+    override val lastPlacedIndex: Int
+        get() = state.layoutInfo.visibleItemsInfo.last().index
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt
index a96a483..e165443 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridAnimateScrollScope.kt
@@ -54,10 +54,9 @@
     }
 
     override fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
-        val visibleItems = state.layoutInfo.visibleItemsInfo
         val slotsPerLine = state.slotsPerLine
         val averageLineMainAxisSize = calculateLineAverageMainAxisSize(
-            visibleItems,
+            state.layoutInfo,
             state.isVertical
         )
         val before = index < firstVisibleItemIndex
@@ -74,9 +73,10 @@
     override val numOfItemsForTeleport: Int get() = 100 * state.slotsPerLine
 
     private fun calculateLineAverageMainAxisSize(
-        visibleItems: List<TvLazyGridItemInfo>,
+        layoutInfo: TvLazyGridLayoutInfo,
         isVertical: Boolean
     ): Int {
+        val visibleItems = layoutInfo.visibleItemsInfo
         val lineOf: (Int) -> Int = {
             if (isVertical) visibleItems[it].row else visibleItems[it].column
         }
@@ -113,7 +113,7 @@
             lineStartIndex = lineEndIndex
         }
 
-        return totalLinesMainAxisSize / linesCount
+        return totalLinesMainAxisSize / linesCount + layoutInfo.mainAxisItemSpacing
     }
 
     override suspend fun scroll(block: suspend ScrollScope.() -> Unit) {
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt
index b197ea3..141748c 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridDsl.kt
@@ -16,7 +16,6 @@
 
 package androidx.tv.foundation.lazy.grid
 
-import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.calculateEndPadding
@@ -68,9 +67,8 @@
     pivotOffsets: PivotOffsets = PivotOffsets(),
     content: TvLazyGridScope.() -> Unit
 ) {
-    val slotSizesSums = rememberColumnWidthSums(columns, horizontalArrangement, contentPadding)
     LazyGrid(
-        slotSizesSums = slotSizesSums,
+        slots = rememberColumnWidthSums(columns, horizontalArrangement, contentPadding),
         modifier = modifier,
         state = state,
         contentPadding = contentPadding,
@@ -117,9 +115,8 @@
     pivotOffsets: PivotOffsets = PivotOffsets(),
     content: TvLazyGridScope.() -> Unit
 ) {
-    val slotSizesSums = rememberRowHeightSums(rows, verticalArrangement, contentPadding)
     LazyGrid(
-        slotSizesSums = slotSizesSums,
+        slots = rememberRowHeightSums(rows, verticalArrangement, contentPadding),
         modifier = modifier,
         state = state,
         contentPadding = contentPadding,
@@ -139,12 +136,12 @@
     columns: TvGridCells,
     horizontalArrangement: Arrangement.Horizontal,
     contentPadding: PaddingValues
-) = remember<Density.(Constraints) -> List<Int>>(
+) = remember<Density.(Constraints) -> LazyGridSlots>(
     columns,
     horizontalArrangement,
     contentPadding,
 ) {
-    { constraints ->
+    GridSlotCache { constraints ->
         require(constraints.maxWidth != Constraints.Infinity) {
             "LazyVerticalGrid's width should be bound by parent."
         }
@@ -155,28 +152,29 @@
             calculateCrossAxisCellSizes(
                 gridWidth,
                 horizontalArrangement.spacing.roundToPx()
-            ).toMutableList().apply {
-                for (i in 1 until size) {
-                    this[i] += this[i - 1]
+            ).toIntArray().let { sizes ->
+                val positions = IntArray(sizes.size)
+                with(horizontalArrangement) {
+                    arrange(gridWidth, sizes, LayoutDirection.Ltr, positions)
                 }
+                LazyGridSlots(sizes, positions)
             }
         }
     }
 }
 
 /** Returns prefix sums of row heights. */
-@OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun rememberRowHeightSums(
     rows: TvGridCells,
     verticalArrangement: Arrangement.Vertical,
     contentPadding: PaddingValues
-) = remember<Density.(Constraints) -> List<Int>>(
+) = remember<Density.(Constraints) -> LazyGridSlots>(
     rows,
     verticalArrangement,
     contentPadding,
 ) {
-    { constraints ->
+    GridSlotCache { constraints ->
         require(constraints.maxHeight != Constraints.Infinity) {
             "LazyHorizontalGrid's height should be bound by parent."
         }
@@ -187,10 +185,39 @@
             calculateCrossAxisCellSizes(
                 gridHeight,
                 verticalArrangement.spacing.roundToPx()
-            ).toMutableList().apply {
-                for (i in 1 until size) {
-                    this[i] += this[i - 1]
+            ).toIntArray().let { sizes ->
+                val positions = IntArray(sizes.size)
+                with(verticalArrangement) {
+                    arrange(gridHeight, sizes, positions)
                 }
+                LazyGridSlots(sizes, positions)
+            }
+        }
+    }
+}
+
+/** measurement cache to avoid recalculating row/column sizes on each scroll. */
+private class GridSlotCache(
+    private val calculation: Density.(Constraints) -> LazyGridSlots
+) : (Density, Constraints) -> LazyGridSlots {
+    private var cachedConstraints = Constraints()
+    private var cachedDensity: Float = 0f
+    private var cachedSizes: LazyGridSlots? = null
+
+    override fun invoke(density: Density, constraints: Constraints): LazyGridSlots {
+        with(density) {
+            if (
+                cachedSizes != null &&
+                cachedConstraints == constraints &&
+                cachedDensity == this.density
+            ) {
+                return cachedSizes!!
+            }
+
+            cachedConstraints = constraints
+            cachedDensity = this.density
+            return calculation(constraints).also {
+                cachedSizes = it
             }
         }
     }
@@ -277,6 +304,44 @@
             return other is Adaptive && minSize == other.minSize
         }
     }
+
+    /**
+     * Defines a grid with as many rows or columns as possible on the condition that
+     * every cell takes exactly [size] space. The remaining space will be arranged through
+     * [TvLazyVerticalGrid] arrangements on corresponding axis. If [size] is larger than
+     * container size, the cell will be size to match the container.
+     *
+     * For example, for the vertical [TvLazyVerticalGrid] FixedSize(20.dp) would mean that
+     * there will be as many columns as possible and every column will be exactly 20.dp.
+     * If the screen is 88.dp wide tne there will be 4 columns 20.dp each with remaining 8.dp
+     * distributed through [Arrangement.Horizontal].
+     */
+    class FixedSize(private val size: Dp) : TvGridCells {
+        init {
+            require(size > 0.dp) { "Provided size $size should be larger than zero." }
+        }
+
+        override fun Density.calculateCrossAxisCellSizes(
+            availableSize: Int,
+            spacing: Int
+        ): List<Int> {
+            val cellSize = size.roundToPx()
+            return if (cellSize + spacing < availableSize + spacing) {
+                val cellCount = (availableSize + spacing) / (cellSize + spacing)
+                List(cellCount) { cellSize }
+            } else {
+                List(1) { availableSize }
+            }
+        }
+
+        override fun hashCode(): Int {
+            return size.hashCode()
+        }
+
+        override fun equals(other: Any?): Boolean {
+            return other is FixedSize && size == other.size
+        }
+    }
 }
 
 private fun calculateCellsCrossAxisSizeImpl(
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
index 23cf6dc..4082594 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
@@ -16,25 +16,12 @@
 
 package androidx.tv.foundation.lazy.grid
 
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.SpringSpec
-import androidx.compose.animation.core.VectorConverter
-import androidx.compose.animation.core.VisibilityThreshold
-import androidx.compose.animation.core.spring
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.util.fastAny
 import androidx.compose.ui.util.fastForEach
-import androidx.compose.ui.util.fastForEachIndexed
 import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
 
 /**
  * Handles the item placement animations when it is set via
@@ -44,25 +31,22 @@
  * offsets and starting the animations.
  */
 @OptIn(ExperimentalFoundationApi::class)
-internal class LazyGridItemPlacementAnimator(
-    private val scope: CoroutineScope,
-    private val isVertical: Boolean
-) {
-    // state containing an animation and all relevant info for each item.
+internal class LazyGridItemPlacementAnimator {
+    // state containing relevant info for active items.
     private val keyToItemInfoMap = mutableMapOf<Any, ItemInfo>()
 
     // snapshot of the key to index map used for the last measuring.
-    private var keyToIndexMap: LazyLayoutKeyIndexMap = LazyLayoutKeyIndexMap.Empty
+    private var keyIndexMap: LazyLayoutKeyIndexMap = LazyLayoutKeyIndexMap.Empty
 
-    // keeps the index of the first visible item.
+    // keeps the index of the first visible index.
     private var firstVisibleIndex = 0
 
     // stored to not allocate it every pass.
     private val movingAwayKeys = LinkedHashSet<Any>()
-    private val movingInFromStartBound = mutableListOf<LazyGridPositionedItem>()
-    private val movingInFromEndBound = mutableListOf<LazyGridPositionedItem>()
-    private val movingAwayToStartBound = mutableListOf<LazyMeasuredItem>()
-    private val movingAwayToEndBound = mutableListOf<LazyMeasuredItem>()
+    private val movingInFromStartBound = mutableListOf<LazyGridMeasuredItem>()
+    private val movingInFromEndBound = mutableListOf<LazyGridMeasuredItem>()
+    private val movingAwayToStartBound = mutableListOf<LazyGridMeasuredItem>()
+    private val movingAwayToEndBound = mutableListOf<LazyGridMeasuredItem>()
 
     /**
      * Should be called after the measuring so we can detect position changes and start animations.
@@ -73,9 +57,10 @@
         consumedScroll: Int,
         layoutWidth: Int,
         layoutHeight: Int,
-        positionedItems: MutableList<LazyGridPositionedItem>,
-        itemProvider: LazyMeasuredItemProvider,
-        spanLayoutProvider: LazyGridSpanLayoutProvider
+        positionedItems: MutableList<LazyGridMeasuredItem>,
+        itemProvider: LazyGridMeasuredItemProvider,
+        spanLayoutProvider: LazyGridSpanLayoutProvider,
+        isVertical: Boolean
     ) {
         if (!positionedItems.fastAny { it.hasAnimations } && keyToItemInfoMap.isEmpty()) {
             // no animations specified - no work needed
@@ -85,25 +70,31 @@
 
         val previousFirstVisibleIndex = firstVisibleIndex
         firstVisibleIndex = positionedItems.firstOrNull()?.index ?: 0
-        val previousKeyToIndexMap = keyToIndexMap
-        keyToIndexMap = itemProvider.keyToIndexMap
+        val previousKeyToIndexMap = keyIndexMap
+        keyIndexMap = itemProvider.keyIndexMap
 
         val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
 
         // the consumed scroll is considered as a delta we don't need to animate
-        val notAnimatableDelta = consumedScroll.toOffset()
+        val scrollOffset = if (isVertical) {
+            IntOffset(0, consumedScroll)
+        } else {
+            IntOffset(consumedScroll, 0)
+        }
 
         // first add all items we had in the previous run
         movingAwayKeys.addAll(keyToItemInfoMap.keys)
         // iterate through the items which are visible (without animated offsets)
         positionedItems.fastForEach { item ->
-            // remove items we have in the current one as they are not disappearing.
+            // remove items we have in the current one as they are still visible.
             movingAwayKeys.remove(item.key)
             if (item.hasAnimations) {
                 val itemInfo = keyToItemInfoMap[item.key]
                 // there is no state associated with this item yet
                 if (itemInfo == null) {
-                    val previousIndex = previousKeyToIndexMap[item.key]
+                    keyToItemInfoMap[item.key] =
+                        ItemInfo(item.crossAxisSize, item.crossAxisOffset)
+                    val previousIndex = previousKeyToIndexMap.getIndex(item.key)
                     if (previousIndex != -1 && item.index != previousIndex) {
                         if (previousIndex < previousFirstVisibleIndex) {
                             // the larger index will be in the start of the list
@@ -112,14 +103,20 @@
                             movingInFromEndBound.add(item)
                         }
                     } else {
-                        keyToItemInfoMap[item.key] = createItemInfo(item)
+                        initializeNode(
+                            item,
+                            item.offset.let { if (item.isVertical) it.y else it.x }
+                        )
                     }
                 } else {
-                    // this item was visible and is still visible.
-                    itemInfo.notAnimatableDelta += notAnimatableDelta // apply new scroll delta
-                    itemInfo.crossAxisSize = item.getCrossAxisSize()
-                    itemInfo.crossAxisOffset = item.getCrossAxisOffset()
-                    startAnimationsIfNeeded(item, itemInfo)
+                    item.forEachNode {
+                        if (it.rawOffset != LazyLayoutAnimateItemModifierNode.NotInitialized) {
+                            it.rawOffset += scrollOffset
+                        }
+                    }
+                    itemInfo.crossAxisSize = item.crossAxisSize
+                    itemInfo.crossAxisOffset = item.crossAxisOffset
+                    startAnimationsIfNeeded(item)
                 }
             } else {
                 // no animation, clean up if needed
@@ -127,129 +124,129 @@
             }
         }
 
-        var currentMainAxisOffset = 0
-        movingInFromStartBound.sortByDescending { previousKeyToIndexMap[it.key] }
+        var accumulatedOffset = 0
         var previousLine = -1
         var previousLineMainAxisSize = 0
+        movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
         movingInFromStartBound.fastForEach { item ->
-            val line = item.line
+            val line = if (isVertical) item.row else item.column
             if (line != -1 && line == previousLine) {
-                previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.getMainAxisSize())
+                previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize)
             } else {
-                currentMainAxisOffset += previousLineMainAxisSize
-                previousLineMainAxisSize = item.getMainAxisSize()
+                accumulatedOffset += previousLineMainAxisSize
+                previousLineMainAxisSize = item.mainAxisSize
                 previousLine = line
             }
-            val mainAxisOffset = 0 - currentMainAxisOffset - item.getMainAxisSize()
-            val itemInfo = createItemInfo(item, mainAxisOffset)
-            keyToItemInfoMap[item.key] = itemInfo
-            startAnimationsIfNeeded(item, itemInfo)
+            val mainAxisOffset = 0 - accumulatedOffset - item.mainAxisSize
+            initializeNode(item, mainAxisOffset)
+            startAnimationsIfNeeded(item)
         }
-        currentMainAxisOffset = 0
+        accumulatedOffset = 0
         previousLine = -1
         previousLineMainAxisSize = 0
-        movingInFromEndBound.sortBy { previousKeyToIndexMap[it.key] }
+        movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
         movingInFromEndBound.fastForEach { item ->
-            val line = item.line
+            val line = if (isVertical) item.row else item.column
             if (line != -1 && line == previousLine) {
-                previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.getMainAxisSize())
+                previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize)
             } else {
-                currentMainAxisOffset += previousLineMainAxisSize
-                previousLineMainAxisSize = item.getMainAxisSize()
+                accumulatedOffset += previousLineMainAxisSize
+                previousLineMainAxisSize = item.mainAxisSize
                 previousLine = line
             }
-            val mainAxisOffset = mainAxisLayoutSize + currentMainAxisOffset
-            val itemInfo = createItemInfo(item, mainAxisOffset)
-            keyToItemInfoMap[item.key] = itemInfo
-            startAnimationsIfNeeded(item, itemInfo)
+            val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
+            initializeNode(item, mainAxisOffset)
+            startAnimationsIfNeeded(item)
         }
 
         movingAwayKeys.forEach { key ->
             // found an item which was in our map previously but is not a part of the
             // positionedItems now
             val itemInfo = keyToItemInfoMap.getValue(key)
-            val newIndex = keyToIndexMap[key]
+            val newIndex = keyIndexMap.getIndex(key)
 
-            // whether the animation associated with the item has been finished or not yet started
-            val inProgress = itemInfo.placeables.fastAny { it.inProgress }
-            if (itemInfo.placeables.isEmpty() ||
-                newIndex == -1 ||
-                (!inProgress && newIndex == previousKeyToIndexMap[key]) ||
-                (!inProgress && !itemInfo.isWithinBounds(mainAxisLayoutSize))
-            ) {
+            if (newIndex == -1) {
                 keyToItemInfoMap.remove(key)
             } else {
                 val item = itemProvider.getAndMeasure(
-                    ItemIndex(newIndex),
+                    newIndex,
                     constraints = if (isVertical) {
                         Constraints.fixedWidth(itemInfo.crossAxisSize)
                     } else {
                         Constraints.fixedHeight(itemInfo.crossAxisSize)
                     }
                 )
-                if (newIndex < firstVisibleIndex) {
-                    movingAwayToStartBound.add(item)
+                // check if we have any active placement animation on the item
+                var inProgress = false
+                repeat(item.placeablesCount) {
+                    if (item.getParentData(it).node?.isAnimationInProgress == true) {
+                        inProgress = true
+                        return@repeat
+                    }
+                }
+                if ((!inProgress && newIndex == previousKeyToIndexMap.getIndex(key))) {
+                    keyToItemInfoMap.remove(key)
                 } else {
-                    movingAwayToEndBound.add(item)
+                    if (newIndex < firstVisibleIndex) {
+                        movingAwayToStartBound.add(item)
+                    } else {
+                        movingAwayToEndBound.add(item)
+                    }
                 }
             }
         }
 
-        currentMainAxisOffset = 0
+        accumulatedOffset = 0
         previousLine = -1
         previousLineMainAxisSize = 0
-        movingAwayToStartBound.sortByDescending { keyToIndexMap[it.key] }
+        movingAwayToStartBound.sortByDescending { keyIndexMap.getIndex(it.key) }
         movingAwayToStartBound.fastForEach { item ->
-            val line = spanLayoutProvider.getLineIndexOfItem(item.index.value).value
+            val line = spanLayoutProvider.getLineIndexOfItem(item.index)
             if (line != -1 && line == previousLine) {
                 previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize)
             } else {
-                currentMainAxisOffset += previousLineMainAxisSize
+                accumulatedOffset += previousLineMainAxisSize
                 previousLineMainAxisSize = item.mainAxisSize
                 previousLine = line
             }
-            val mainAxisOffset = 0 - currentMainAxisOffset - item.mainAxisSize
+            val mainAxisOffset = 0 - accumulatedOffset - item.mainAxisSize
 
             val itemInfo = keyToItemInfoMap.getValue(item.key)
 
-            val positionedItem = item.position(
-                mainAxisOffset,
-                itemInfo.crossAxisOffset,
-                layoutWidth,
-                layoutHeight,
-                TvLazyGridItemInfo.UnknownRow,
-                TvLazyGridItemInfo.UnknownColumn
+            item.position(
+                mainAxisOffset = mainAxisOffset,
+                crossAxisOffset = itemInfo.crossAxisOffset,
+                layoutWidth = layoutWidth,
+                layoutHeight = layoutHeight
             )
-            positionedItems.add(positionedItem)
-            startAnimationsIfNeeded(positionedItem, itemInfo)
+            positionedItems.add(item)
+            startAnimationsIfNeeded(item)
         }
-        currentMainAxisOffset = 0
+        accumulatedOffset = 0
         previousLine = -1
         previousLineMainAxisSize = 0
-        movingAwayToEndBound.sortBy { keyToIndexMap[it.key] }
+        movingAwayToEndBound.sortBy { keyIndexMap.getIndex(it.key) }
         movingAwayToEndBound.fastForEach { item ->
-            val line = spanLayoutProvider.getLineIndexOfItem(item.index.value).value
+            val line = spanLayoutProvider.getLineIndexOfItem(item.index)
             if (line != -1 && line == previousLine) {
                 previousLineMainAxisSize = maxOf(previousLineMainAxisSize, item.mainAxisSize)
             } else {
-                currentMainAxisOffset += previousLineMainAxisSize
+                accumulatedOffset += previousLineMainAxisSize
                 previousLineMainAxisSize = item.mainAxisSize
                 previousLine = line
             }
-            val mainAxisOffset = mainAxisLayoutSize + currentMainAxisOffset
+            val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
 
             val itemInfo = keyToItemInfoMap.getValue(item.key)
-            val positionedItem = item.position(
-                mainAxisOffset,
-                itemInfo.crossAxisOffset,
-                layoutWidth,
-                layoutHeight,
-                TvLazyGridItemInfo.UnknownRow,
-                TvLazyGridItemInfo.UnknownColumn
+            item.position(
+                mainAxisOffset = mainAxisOffset,
+                crossAxisOffset = itemInfo.crossAxisOffset,
+                layoutWidth = layoutWidth,
+                layoutHeight = layoutHeight,
             )
 
-            positionedItems.add(positionedItem)
-            startAnimationsIfNeeded(positionedItem, itemInfo)
+            positionedItems.add(item)
+            startAnimationsIfNeeded(item)
         }
 
         movingInFromStartBound.clear()
@@ -260,155 +257,66 @@
     }
 
     /**
-     * Returns the current animated item placement offset. By calling it only during the layout
-     * phase we can skip doing remeasure on every animation frame.
-     */
-    fun getAnimatedOffset(
-        key: Any,
-        placeableIndex: Int,
-        minOffset: Int,
-        maxOffset: Int,
-        rawOffset: IntOffset
-    ): IntOffset {
-        val itemInfo = keyToItemInfoMap[key] ?: return rawOffset
-        val item = itemInfo.placeables[placeableIndex]
-        val currentValue = item.animatedOffset.value + itemInfo.notAnimatableDelta
-        val currentTarget = item.targetOffset + itemInfo.notAnimatableDelta
-
-        // cancel the animation if it is fully out of the bounds.
-        if (item.inProgress &&
-            ((currentTarget.mainAxis <= minOffset && currentValue.mainAxis < minOffset) ||
-                (currentTarget.mainAxis >= maxOffset && currentValue.mainAxis > maxOffset))
-        ) {
-            scope.launch {
-                item.animatedOffset.snapTo(item.targetOffset)
-                item.inProgress = false
-            }
-        }
-
-        return currentValue
-    }
-
-    /**
      * Should be called when the animations are not needed for the next positions change,
      * for example when we snap to a new position.
      */
     fun reset() {
         keyToItemInfoMap.clear()
-        keyToIndexMap = LazyLayoutKeyIndexMap.Empty
+        keyIndexMap = LazyLayoutKeyIndexMap.Empty
         firstVisibleIndex = -1
     }
 
-    private fun createItemInfo(
-        item: LazyGridPositionedItem,
-        mainAxisOffset: Int = item.offset.mainAxis
-    ): ItemInfo {
-        val newItemInfo = ItemInfo(item.getCrossAxisSize(), item.getCrossAxisOffset())
-        val targetOffset = if (isVertical) {
-            item.offset.copy(y = mainAxisOffset)
+    private fun initializeNode(
+        item: LazyGridMeasuredItem,
+        mainAxisOffset: Int
+    ) {
+        val firstPlaceableOffset = item.offset
+
+        val targetFirstPlaceableOffset = if (item.isVertical) {
+            firstPlaceableOffset.copy(y = mainAxisOffset)
         } else {
-            item.offset.copy(x = mainAxisOffset)
+            firstPlaceableOffset.copy(x = mainAxisOffset)
         }
 
-        // populate placeable info list
-        repeat(item.placeablesCount) { placeableIndex ->
-            newItemInfo.placeables.add(
-                PlaceableInfo(
-                    targetOffset,
-                    item.getMainAxisSize(placeableIndex)
-                )
-            )
+        // initialize offsets
+        item.forEachNode { node ->
+            val diffToFirstPlaceableOffset =
+                item.offset - firstPlaceableOffset
+            node.rawOffset = targetFirstPlaceableOffset + diffToFirstPlaceableOffset
         }
-        return newItemInfo
     }
 
-    private fun startAnimationsIfNeeded(item: LazyGridPositionedItem, itemInfo: ItemInfo) {
-        // first we make sure our item info is up to date (has the item placeables count)
-        while (itemInfo.placeables.size > item.placeablesCount) {
-            itemInfo.placeables.removeLast()
-        }
-        while (itemInfo.placeables.size < item.placeablesCount) {
-            val newPlaceableInfoIndex = itemInfo.placeables.size
-            val rawOffset = item.offset
-            itemInfo.placeables.add(
-                PlaceableInfo(
-                    rawOffset - itemInfo.notAnimatableDelta,
-                    item.getMainAxisSize(newPlaceableInfoIndex)
-                )
-            )
-        }
-
-        itemInfo.placeables.fastForEachIndexed { index, placeableInfo ->
-            val currentTarget = placeableInfo.targetOffset + itemInfo.notAnimatableDelta
-            val currentOffset = item.offset
-            placeableInfo.mainAxisSize = item.getMainAxisSize(index)
-            val animationSpec = item.getAnimationSpec(index)
-            if (currentTarget != currentOffset) {
-                placeableInfo.targetOffset = currentOffset - itemInfo.notAnimatableDelta
-                if (animationSpec != null) {
-                    placeableInfo.inProgress = true
-                    scope.launch {
-                        val finalSpec = if (placeableInfo.animatedOffset.isRunning) {
-                            // when interrupted, use the default spring, unless the spec is a spring.
-                            if (animationSpec is SpringSpec<IntOffset>) animationSpec else
-                                InterruptionSpec
-                        } else {
-                            animationSpec
-                        }
-
-                        try {
-                            placeableInfo.animatedOffset.animateTo(
-                                placeableInfo.targetOffset,
-                                finalSpec
-                            )
-                            placeableInfo.inProgress = false
-                        } catch (_: CancellationException) {
-                            // we don't reset inProgress in case of cancellation as it means
-                            // there is a new animation started which would reset it later
-                        }
-                    }
-                }
+    private fun startAnimationsIfNeeded(item: LazyGridMeasuredItem) {
+        item.forEachNode { node ->
+            val newTarget = item.offset
+            val currentTarget = node.rawOffset
+            if (currentTarget != LazyLayoutAnimateItemModifierNode.NotInitialized &&
+                currentTarget != newTarget
+            ) {
+                node.animatePlacementDelta(newTarget - currentTarget)
             }
+            node.rawOffset = newTarget
         }
     }
 
-    /**
-     * Whether at least one placeable is within the viewport bounds.
-     */
-    private fun ItemInfo.isWithinBounds(mainAxisLayoutSize: Int): Boolean {
-        return placeables.fastAny {
-            val currentTarget = it.targetOffset + notAnimatableDelta
-            currentTarget.mainAxis + it.mainAxisSize > 0 &&
-                currentTarget.mainAxis < mainAxisLayoutSize
+    private val Any?.node get() = this as? LazyLayoutAnimateItemModifierNode
+
+    private val LazyGridMeasuredItem.hasAnimations: Boolean
+        get() {
+            forEachNode { return true }
+            return false
+        }
+
+    private inline fun LazyGridMeasuredItem.forEachNode(
+        block: (LazyLayoutAnimateItemModifierNode) -> Unit
+    ) {
+        repeat(placeablesCount) { index ->
+            getParentData(index).node?.let(block)
         }
     }
-
-    private fun Int.toOffset() =
-        IntOffset(if (isVertical) 0 else this, if (!isVertical) 0 else this)
-
-    private val IntOffset.mainAxis get() = if (isVertical) y else x
-
-    private val LazyGridPositionedItem.line get() = if (isVertical) row else column
 }
 
 private class ItemInfo(
     var crossAxisSize: Int,
     var crossAxisOffset: Int
-) {
-    var notAnimatableDelta: IntOffset = IntOffset.Zero
-    val placeables = mutableListOf<PlaceableInfo>()
-}
-
-private class PlaceableInfo(initialOffset: IntOffset, var mainAxisSize: Int) {
-    val animatedOffset = Animatable(initialOffset, IntOffset.VectorConverter)
-    var targetOffset: IntOffset = initialOffset
-    var inProgress by mutableStateOf(false)
-}
-
-/**
- * We switch to this spec when a duration based animation is being interrupted.
- */
-private val InterruptionSpec = spring(
-    stiffness = Spring.StiffnessMediumLow,
-    visibilityThreshold = IntOffset.VisibilityThreshold
 )
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
index 8e23cfe..09d6569 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridItemProvider.kt
@@ -21,79 +21,81 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
 import androidx.compose.runtime.referentialEqualityPolicy
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-import androidx.tv.foundation.lazy.layout.NearestRangeKeyIndexMapState
+import androidx.tv.foundation.lazy.layout.NearestRangeKeyIndexMap
 
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @ExperimentalFoundationApi
 internal interface LazyGridItemProvider : LazyLayoutItemProvider {
+    val keyIndexMap: LazyLayoutKeyIndexMap
     val spanLayoutProvider: LazyGridSpanLayoutProvider
-    val keyToIndexMap: LazyLayoutKeyIndexMap
 }
 
 @ExperimentalFoundationApi
 @Composable
-internal fun rememberLazyGridItemProvider(
+internal fun rememberLazyGridItemProviderLambda(
     state: TvLazyGridState,
     content: TvLazyGridScope.() -> Unit,
-): LazyGridItemProvider {
+): () -> LazyGridItemProvider {
     val latestContent = rememberUpdatedState(content)
     return remember(state) {
-        LazyGridItemProviderImpl(
-            state,
-            { latestContent.value },
-        )
+        val intervalContentState = derivedStateOf(referentialEqualityPolicy()) {
+            LazyGridIntervalContent(latestContent.value)
+        }
+        val itemProviderState = derivedStateOf(referentialEqualityPolicy()) {
+            val intervalContent = intervalContentState.value
+            val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent)
+            LazyGridItemProviderImpl(
+                state = state,
+                intervalContent = intervalContent,
+                keyIndexMap = map
+            )
+        }
+        itemProviderState::value
     }
 }
 
 @ExperimentalFoundationApi
 private class LazyGridItemProviderImpl(
     private val state: TvLazyGridState,
-    private val latestContent: () -> (TvLazyGridScope.() -> Unit)
+    private val intervalContent: LazyGridIntervalContent,
+    override val keyIndexMap: LazyLayoutKeyIndexMap,
 ) : LazyGridItemProvider {
-    private val gridContent by derivedStateOf(referentialEqualityPolicy()) {
-        LazyGridIntervalContent(latestContent())
-    }
 
-    override val itemCount: Int get() = gridContent.itemCount
+    override val itemCount: Int get() = intervalContent.itemCount
 
-    override fun getKey(index: Int): Any = gridContent.getKey(index)
+    override fun getKey(index: Int): Any =
+        keyIndexMap.getKey(index) ?: intervalContent.getKey(index)
 
-    override fun getContentType(index: Int): Any? = gridContent.getContentType(index)
+    override fun getContentType(index: Int): Any? = intervalContent.getContentType(index)
 
     @Composable
     override fun Item(index: Int, key: Any) {
         LazyLayoutPinnableItem(key, index, state.pinnedItems) {
-            gridContent.withInterval(index) { localIndex, content ->
+            intervalContent.withInterval(index) { localIndex, content ->
                 content.item(TvLazyGridItemScopeImpl, localIndex)
             }
         }
     }
 
     override val spanLayoutProvider: LazyGridSpanLayoutProvider
-        get() = gridContent.spanLayoutProvider
+        get() = intervalContent.spanLayoutProvider
 
-    override val keyToIndexMap: LazyLayoutKeyIndexMap by NearestRangeKeyIndexMapState(
-        firstVisibleItemIndex = { state.firstVisibleItemIndex },
-        slidingWindowSize = { NearestItemsSlidingWindowSize },
-        extraItemCount = { NearestItemsExtraItemCount },
-        content = { gridContent }
-    )
+    override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key)
 
-    override fun getIndex(key: Any): Int = keyToIndexMap[key]
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is LazyGridItemProviderImpl) return false
+
+        // the identity of this class is represented by intervalContent object.
+        // having equals() allows us to skip items recomposition when intervalContent didn't change
+        return intervalContent == other.intervalContent
+    }
+
+    override fun hashCode(): Int {
+        return intervalContent.hashCode()
+    }
 }
-
-/**
- * We use the idea of sliding window as an optimization, so user can scroll up to this number of
- * items until we have to regenerate the key to index map.
- */
-private const val NearestItemsSlidingWindowSize = 90
-
-/**
- * The minimum amount of items near the current first visible item we want to have mapping for.
- */
-private const val NearestItemsExtraItemCount = 200
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
index 35556ec..f602de1 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasure.kt
@@ -19,7 +19,7 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
+import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
@@ -29,6 +29,7 @@
 import androidx.compose.ui.unit.constrainWidth
 import androidx.compose.ui.util.fastForEach
 import androidx.compose.ui.util.fastSumBy
+import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
 import androidx.tv.foundation.lazy.list.fastFilter
 import kotlin.math.abs
 import kotlin.math.min
@@ -43,14 +44,13 @@
 @OptIn(ExperimentalFoundationApi::class)
 internal fun measureLazyGrid(
     itemsCount: Int,
-    itemProvider: LazyGridItemProvider,
-    measuredLineProvider: LazyMeasuredLineProvider,
-    measuredItemProvider: LazyMeasuredItemProvider,
+    measuredLineProvider: LazyGridMeasuredLineProvider,
+    measuredItemProvider: LazyGridMeasuredItemProvider,
     mainAxisAvailableSize: Int,
     beforeContentPadding: Int,
     afterContentPadding: Int,
     spaceBetweenLines: Int,
-    firstVisibleLineIndex: LineIndex,
+    firstVisibleLineIndex: Int,
     firstVisibleLineScrollOffset: Int,
     scrollToBeConsumed: Float,
     constraints: Constraints,
@@ -61,7 +61,7 @@
     density: Density,
     placementAnimator: LazyGridItemPlacementAnimator,
     spanLayoutProvider: LazyGridSpanLayoutProvider,
-    pinnedItems: LazyLayoutPinnedItemList,
+    pinnedItems: List<Int>,
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): TvLazyGridMeasureResult {
     require(beforeContentPadding >= 0) { "negative beforeContentPadding" }
@@ -95,13 +95,13 @@
         currentFirstLineScrollOffset -= scrollDelta
 
         // if the current scroll offset is less than minimally possible
-        if (currentFirstLineIndex == LineIndex(0) && currentFirstLineScrollOffset < 0) {
+        if (currentFirstLineIndex == 0 && currentFirstLineScrollOffset < 0) {
             scrollDelta += currentFirstLineScrollOffset
             currentFirstLineScrollOffset = 0
         }
 
         // this will contain all the MeasuredItems representing the visible lines
-        val visibleLines = mutableListOf<LazyMeasuredLine>()
+        val visibleLines = ArrayDeque<LazyGridMeasuredLine>()
 
         // define min and max offsets
         val minOffset = -beforeContentPadding + if (spaceBetweenLines < 0) spaceBetweenLines else 0
@@ -115,8 +115,8 @@
         // we had scrolled backward or we compose items in the start padding area, which means
         // items before current firstLineScrollOffset should be visible. compose them and update
         // firstLineScrollOffset
-        while (currentFirstLineScrollOffset < 0 && currentFirstLineIndex > LineIndex(0)) {
-            val previous = LineIndex(currentFirstLineIndex.value - 1)
+        while (currentFirstLineScrollOffset < 0 && currentFirstLineIndex > 0) {
+            val previous = currentFirstLineIndex - 1
             val measuredLine = measuredLineProvider.getAndMeasure(previous)
             visibleLines.add(0, measuredLine)
             currentFirstLineScrollOffset += measuredLine.mainAxisSizeWithSpacings
@@ -146,7 +146,7 @@
         // then composing visible lines forward until we fill the whole viewport.
         // we want to have at least one line in visibleItems even if in fact all the items are
         // offscreen, this can happen if the content padding is larger than the available size.
-        while (index.value < itemsCount &&
+        while (index < itemsCount &&
             (currentMainAxisOffset < maxMainAxis ||
                 currentMainAxisOffset <= 0 || // filling beforeContentPadding area
                 visibleLines.isEmpty())
@@ -158,7 +158,7 @@
 
             currentMainAxisOffset += measuredLine.mainAxisSizeWithSpacings
             if (currentMainAxisOffset <= minOffset &&
-                measuredLine.items.last().index.value != itemsCount - 1) {
+                measuredLine.items.last().index != itemsCount - 1) {
                 // this line is offscreen and will not be placed. advance firstVisibleLineIndex
                 currentFirstLineIndex = index + 1
                 currentFirstLineScrollOffset -= measuredLine.mainAxisSizeWithSpacings
@@ -174,10 +174,10 @@
             val toScrollBack = maxOffset - currentMainAxisOffset
             currentFirstLineScrollOffset -= toScrollBack
             currentMainAxisOffset += toScrollBack
-            while (currentFirstLineScrollOffset < beforeContentPadding &&
-                currentFirstLineIndex > LineIndex(0)
+            while (
+                currentFirstLineScrollOffset < beforeContentPadding && currentFirstLineIndex > 0
             ) {
-                val previousIndex = LineIndex(currentFirstLineIndex.value - 1)
+                val previousIndex = currentFirstLineIndex - 1
                 val measuredLine = measuredLineProvider.getAndMeasure(previousIndex)
                 visibleLines.add(0, measuredLine)
                 currentFirstLineScrollOffset += measuredLine.mainAxisSizeWithSpacings
@@ -204,16 +204,15 @@
         }
 
         // the initial offset for lines from visibleLines list
-        require(currentFirstLineScrollOffset >= 0) { "invalid initial offset" }
+        require(currentFirstLineScrollOffset >= 0) { "negative initial offset" }
         val visibleLinesScrollOffset = -currentFirstLineScrollOffset
         var firstLine = visibleLines.first()
 
-        val firstItemIndex = firstLine.items.firstOrNull()?.index?.value ?: 0
-        val lastItemIndex = visibleLines.lastOrNull()?.items?.lastOrNull()?.index?.value ?: 0
+        val firstItemIndex = firstLine.items.firstOrNull()?.index ?: 0
+        val lastItemIndex = visibleLines.lastOrNull()?.items?.lastOrNull()?.index ?: 0
         val extraItemsBefore = calculateExtraItems(
             pinnedItems,
             measuredItemProvider,
-            itemProvider,
             itemConstraints = { measuredLineProvider.itemConstraints(it) },
             filter = { it in 0 until firstItemIndex }
         )
@@ -221,7 +220,6 @@
         val extraItemsAfter = calculateExtraItems(
             pinnedItems,
             measuredItemProvider,
-            itemProvider,
             itemConstraints = { measuredLineProvider.itemConstraints(it) },
             filter = { it in (lastItemIndex + 1) until itemsCount }
         )
@@ -274,7 +272,8 @@
             layoutHeight = layoutHeight,
             positionedItems = positionedItems,
             itemProvider = measuredItemProvider,
-            spanLayoutProvider = spanLayoutProvider
+            spanLayoutProvider = spanLayoutProvider,
+            isVertical = isVertical
         )
 
         return TvLazyGridMeasureResult(
@@ -307,21 +306,18 @@
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @ExperimentalFoundationApi
 private inline fun calculateExtraItems(
-    pinnedItems: LazyLayoutPinnedItemList,
-    measuredItemProvider: LazyMeasuredItemProvider,
-    itemProvider: LazyGridItemProvider,
-    itemConstraints: (ItemIndex) -> Constraints,
+    pinnedItems: List<Int>,
+    measuredItemProvider: LazyGridMeasuredItemProvider,
+    itemConstraints: (Int) -> Constraints,
     filter: (Int) -> Boolean
-): List<LazyMeasuredItem> {
-    var items: MutableList<LazyMeasuredItem>? = null
+): List<LazyGridMeasuredItem> {
+    var items: MutableList<LazyGridMeasuredItem>? = null
 
-    pinnedItems.fastForEach { item ->
-        val index = itemProvider.findIndexByKey(item.key, item.index)
+    pinnedItems.fastForEach { index ->
         if (filter(index)) {
-            val itemIndex = ItemIndex(index)
-            val constraints = itemConstraints(itemIndex)
+            val constraints = itemConstraints(index)
             val measuredItem = measuredItemProvider.getAndMeasure(
-                itemIndex,
+                index,
                 constraints = constraints
             )
             if (items == null) {
@@ -335,12 +331,12 @@
 }
 
 /**
- * Calculates [LazyMeasuredLine]s offsets.
+ * Calculates [LazyGridMeasuredLine]s offsets.
  */
 private fun calculateItemsOffsets(
-    lines: List<LazyMeasuredLine>,
-    itemsBefore: List<LazyMeasuredItem>,
-    itemsAfter: List<LazyMeasuredItem>,
+    lines: List<LazyGridMeasuredLine>,
+    itemsBefore: List<LazyGridMeasuredItem>,
+    itemsAfter: List<LazyGridMeasuredItem>,
     layoutWidth: Int,
     layoutHeight: Int,
     finalMainAxisOffset: Int,
@@ -351,19 +347,17 @@
     horizontalArrangement: Arrangement.Horizontal?,
     reverseLayout: Boolean,
     density: Density,
-): MutableList<LazyGridPositionedItem> {
+): MutableList<LazyGridMeasuredItem> {
     val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
     val hasSpareSpace = finalMainAxisOffset < min(mainAxisLayoutSize, maxOffset)
     if (hasSpareSpace) {
-        check(firstLineScrollOffset == 0) { "invalid firstLineScrollOffset" }
+        check(firstLineScrollOffset == 0) { "non-zero firstLineScrollOffset" }
     }
 
-    val positionedItems = ArrayList<LazyGridPositionedItem>(lines.fastSumBy { it.items.size })
+    val positionedItems = ArrayList<LazyGridMeasuredItem>(lines.fastSumBy { it.items.size })
 
     if (hasSpareSpace) {
-        require(itemsBefore.isEmpty() && itemsAfter.isEmpty()) {
-            "existing out of bounds items"
-        }
+        require(itemsBefore.isEmpty() && itemsAfter.isEmpty()) { "no items" }
         val linesCount = lines.size
         fun Int.reverseAware() =
             if (!reverseLayout) this else linesCount - this - 1
@@ -373,19 +367,11 @@
         }
         val offsets = IntArray(linesCount) { 0 }
         if (isVertical) {
-            with(
-                requireNotNull(verticalArrangement) {
-                    "null verticalArrangement when isVertical = true"
-                }
-            ) {
+            with(requireNotNull(verticalArrangement) { "null verticalArrangement" }) {
                 density.arrange(mainAxisLayoutSize, sizes, offsets)
             }
         } else {
-            with(
-                requireNotNull(horizontalArrangement) {
-                    "null horizontalArrangement when isVertical = false"
-                }
-            ) {
+            with(requireNotNull(horizontalArrangement) { "null horizontalArrangement" }) {
                 // Enforces Ltr layout direction as it is mirrored with placeRelative later.
                 density.arrange(mainAxisLayoutSize, sizes, LayoutDirection.Ltr, offsets)
             }
@@ -413,7 +399,8 @@
 
         itemsBefore.fastForEach {
             currentMainAxis -= it.mainAxisSizeWithSpacings
-            positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight))
+            it.position(currentMainAxis, 0, layoutWidth, layoutHeight)
+            positionedItems.add(it)
         }
 
         currentMainAxis = firstLineScrollOffset
@@ -423,23 +410,148 @@
         }
 
         itemsAfter.fastForEach {
-            positionedItems.add(it.positionExtraItem(currentMainAxis, layoutWidth, layoutHeight))
+            it.position(currentMainAxis, 0, layoutWidth, layoutHeight)
+            positionedItems.add(it)
             currentMainAxis += it.mainAxisSizeWithSpacings
         }
     }
     return positionedItems
 }
 
-private fun LazyMeasuredItem.positionExtraItem(
-    mainAxisOffset: Int,
-    layoutWidth: Int,
-    layoutHeight: Int
-): LazyGridPositionedItem =
-    position(
-        mainAxisOffset = mainAxisOffset,
-        crossAxisOffset = 0,
-        layoutWidth = layoutWidth,
-        layoutHeight = layoutHeight,
-        row = 0,
-        column = 0
-    )
+/**
+ * Abstracts away subcomposition and span calculation from the measuring logic of entire lines.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal abstract class LazyGridMeasuredLineProvider(
+    private val isVertical: Boolean,
+    private val slots: LazyGridSlots,
+    private val gridItemsCount: Int,
+    private val spaceBetweenLines: Int,
+    private val measuredItemProvider: LazyGridMeasuredItemProvider,
+    private val spanLayoutProvider: LazyGridSpanLayoutProvider
+) {
+    // The constraints for cross axis size. The main axis is not restricted.
+    internal fun childConstraints(startSlot: Int, span: Int): Constraints {
+        val crossAxisSize = if (span == 1) {
+            slots.sizes[startSlot]
+        } else {
+            val endSlot = startSlot + span - 1
+            slots.positions[endSlot] + slots.sizes[endSlot] - slots.positions[startSlot]
+        }.coerceAtLeast(0)
+        return if (isVertical) {
+            Constraints.fixedWidth(crossAxisSize)
+        } else {
+            Constraints.fixedHeight(crossAxisSize)
+        }
+    }
+
+    fun itemConstraints(itemIndex: Int): Constraints {
+        val span = spanLayoutProvider.spanOf(
+            itemIndex,
+            spanLayoutProvider.slotsPerLine
+        )
+        return childConstraints(0, span)
+    }
+
+    /**
+     * Used to subcompose items on lines of lazy grids. Composed placeables will be measured
+     * with the correct constraints and wrapped into [LazyGridMeasuredLine].
+     */
+    fun getAndMeasure(lineIndex: Int): LazyGridMeasuredLine {
+        val lineConfiguration = spanLayoutProvider.getLineConfiguration(lineIndex)
+        val lineItemsCount = lineConfiguration.spans.size
+
+        // we add space between lines as an extra spacing for all lines apart from the last one
+        // so the lazy grid measuring logic will take it into account.
+        val mainAxisSpacing = if (lineItemsCount == 0 ||
+            lineConfiguration.firstItemIndex + lineItemsCount == gridItemsCount
+        ) {
+            0
+        } else {
+            spaceBetweenLines
+        }
+
+        var startSlot = 0
+        val items = Array(lineItemsCount) {
+            val span = lineConfiguration.spans[it].currentLineSpan
+            val constraints = childConstraints(startSlot, span)
+            measuredItemProvider.getAndMeasure(
+                lineConfiguration.firstItemIndex + it,
+                mainAxisSpacing,
+                constraints
+            ).also { startSlot += span }
+        }
+        return createLine(
+            lineIndex,
+            items,
+            lineConfiguration.spans,
+            mainAxisSpacing
+        )
+    }
+
+    /**
+     * Contains the mapping between the key and the index. It could contain not all the items of
+     * the list as an optimization.
+     */
+    val keyIndexMap: LazyLayoutKeyIndexMap get() = measuredItemProvider.keyIndexMap
+
+    abstract fun createLine(
+        index: Int,
+        items: Array<LazyGridMeasuredItem>,
+        spans: List<TvGridItemSpan>,
+        mainAxisSpacing: Int
+    ): LazyGridMeasuredLine
+}
+
+/**
+ * Abstracts away the subcomposition from the measuring logic.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+internal abstract class LazyGridMeasuredItemProvider @ExperimentalFoundationApi constructor(
+    private val itemProvider: LazyGridItemProvider,
+    private val measureScope: LazyLayoutMeasureScope,
+    private val defaultMainAxisSpacing: Int
+) {
+    /**
+     * Used to subcompose individual items of lazy grids. Composed placeables will be measured
+     * with the provided [constraints] and wrapped into [LazyGridMeasuredItem].
+     */
+    fun getAndMeasure(
+        index: Int,
+        mainAxisSpacing: Int = defaultMainAxisSpacing,
+        constraints: Constraints
+    ): LazyGridMeasuredItem {
+        val key = itemProvider.getKey(index)
+        val contentType = itemProvider.getContentType(index)
+        val placeables = measureScope.measure(index, constraints)
+        val crossAxisSize = if (constraints.hasFixedWidth) {
+            constraints.minWidth
+        } else {
+            require(constraints.hasFixedHeight) { "does not have fixed height" }
+            constraints.minHeight
+        }
+        return createItem(
+            index,
+            key,
+            contentType,
+            crossAxisSize,
+            mainAxisSpacing,
+            placeables
+        )
+    }
+
+    /**
+     * Contains the mapping between the key and the index. It could contain not all the items of
+     * the list as an optimization.
+     */
+    val keyIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyIndexMap
+
+    abstract fun createItem(
+        index: Int,
+        key: Any,
+        contentType: Any?,
+        crossAxisSize: Int,
+        mainAxisSpacing: Int,
+        placeables: List<Placeable>
+    ): LazyGridMeasuredItem
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredItem.kt
new file mode 100644
index 0000000..88fa842
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredItem.kt
@@ -0,0 +1,166 @@
+/*
+ * 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.tv.foundation.lazy.grid
+
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastForEach
+
+/**
+ * Represents one measured item of the lazy grid. It can in fact consist of multiple placeables
+ * if the user emit multiple layout nodes in the item callback.
+ */
+internal class LazyGridMeasuredItem(
+    override val index: Int,
+    override val key: Any,
+    val isVertical: Boolean,
+    /**
+     * Cross axis size is the same for all [placeables]. Take it as parameter for the case when
+     * [placeables] is empty.
+     */
+    val crossAxisSize: Int,
+    mainAxisSpacing: Int,
+    private val reverseLayout: Boolean,
+    private val layoutDirection: LayoutDirection,
+    private val beforeContentPadding: Int,
+    private val afterContentPadding: Int,
+    private val placeables: List<Placeable>,
+    /**
+     * The offset which shouldn't affect any calculations but needs to be applied for the final
+     * value passed into the place() call.
+     */
+    private val visualOffset: IntOffset,
+    override val contentType: Any?
+) : TvLazyGridItemInfo {
+    /**
+     * Main axis size of the item - the max main axis size of the placeables.
+     */
+    val mainAxisSize: Int
+
+    /**
+     * The max main axis size of the placeables plus mainAxisSpacing.
+     */
+    val mainAxisSizeWithSpacings: Int
+
+    val placeablesCount: Int get() = placeables.size
+
+    private var mainAxisLayoutSize: Int = Unset
+    private var minMainAxisOffset: Int = 0
+    private var maxMainAxisOffset: Int = 0
+
+    fun getParentData(index: Int) = placeables[index].parentData
+
+    init {
+        var maxMainAxis = 0
+        placeables.fastForEach {
+            maxMainAxis = maxOf(maxMainAxis, if (isVertical) it.height else it.width)
+        }
+        mainAxisSize = maxMainAxis
+        mainAxisSizeWithSpacings = (maxMainAxis + mainAxisSpacing).coerceAtLeast(0)
+    }
+
+    override val size: IntSize = if (isVertical) {
+        IntSize(crossAxisSize, mainAxisSize)
+    } else {
+        IntSize(mainAxisSize, crossAxisSize)
+    }
+    override var offset: IntOffset = IntOffset.Zero
+        private set
+    val crossAxisOffset get() = if (isVertical) offset.x else offset.y
+    override var row: Int = TvLazyGridItemInfo.UnknownRow
+        private set
+    override var column: Int = TvLazyGridItemInfo.UnknownColumn
+        private set
+
+    /**
+     * Calculates positions for the inner placeables at [mainAxisOffset], [crossAxisOffset].
+     * [layoutWidth] and [layoutHeight] should be provided to not place placeables which are ended
+     * up outside of the viewport (for example one item consist of 2 placeables, and the first one
+     * is not going to be visible, so we don't place it as an optimization, but place the second
+     * one). If [reverseOrder] is true the inner placeables would be placed in the inverted order.
+     */
+    fun position(
+        mainAxisOffset: Int,
+        crossAxisOffset: Int,
+        layoutWidth: Int,
+        layoutHeight: Int,
+        row: Int = TvLazyGridItemInfo.UnknownRow,
+        column: Int = TvLazyGridItemInfo.UnknownColumn
+    ) {
+        mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+        val crossAxisLayoutSize = if (isVertical) layoutWidth else layoutHeight
+        @Suppress("NAME_SHADOWING")
+        val crossAxisOffset = if (isVertical && layoutDirection == LayoutDirection.Rtl) {
+            crossAxisLayoutSize - crossAxisOffset - crossAxisSize
+        } else {
+            crossAxisOffset
+        }
+        offset = if (isVertical) {
+            IntOffset(crossAxisOffset, mainAxisOffset)
+        } else {
+            IntOffset(mainAxisOffset, crossAxisOffset)
+        }
+        this.row = row
+        this.column = column
+        minMainAxisOffset = -beforeContentPadding
+        maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding
+    }
+
+    fun place(
+        scope: Placeable.PlacementScope,
+    ) = with(scope) {
+        require(mainAxisLayoutSize != Unset) { "position() should be called first" }
+        repeat(placeablesCount) { index ->
+            val placeable = placeables[index]
+            val minOffset = minMainAxisOffset - placeable.mainAxisSize
+            val maxOffset = maxMainAxisOffset
+
+            var offset = offset
+            val animateNode = getParentData(index) as? LazyLayoutAnimateItemModifierNode
+            if (animateNode != null) {
+                val animatedOffset = offset + animateNode.placementDelta
+                // cancel the animation if current and target offsets are both out of the bounds.
+                if ((offset.mainAxis <= minOffset && animatedOffset.mainAxis <= minOffset) ||
+                    (offset.mainAxis >= maxOffset && animatedOffset.mainAxis >= maxOffset)
+                ) {
+                    animateNode.cancelAnimation()
+                }
+                offset = animatedOffset
+            }
+            if (reverseLayout) {
+                offset = offset.copy { mainAxisOffset ->
+                    mainAxisLayoutSize - mainAxisOffset - placeable.mainAxisSize
+                }
+            }
+            offset += visualOffset
+            if (isVertical) {
+                placeable.placeWithLayer(offset)
+            } else {
+                placeable.placeRelativeWithLayer(offset)
+            }
+        }
+    }
+
+    private val IntOffset.mainAxis get() = if (isVertical) y else x
+    private val Placeable.mainAxisSize get() = if (isVertical) height else width
+    private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset =
+        IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y)
+}
+
+private const val Unset = Int.MIN_VALUE
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredLine.kt
similarity index 74%
rename from tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt
rename to tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredLine.kt
index abe30a5..2b96d7b 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLine.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridMeasuredLine.kt
@@ -17,25 +17,22 @@
 package androidx.tv.foundation.lazy.grid
 
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.ui.unit.LayoutDirection
 
 /**
  * Represents one measured line of the lazy list. Each item on the line can in fact consist of
  * multiple placeables if the user emit multiple layout nodes in the item callback.
  */
 @OptIn(ExperimentalFoundationApi::class)
-internal class LazyMeasuredLine constructor(
-    val index: LineIndex,
-    val items: Array<LazyMeasuredItem>,
+internal class LazyGridMeasuredLine constructor(
+    val index: Int,
+    val items: Array<LazyGridMeasuredItem>,
+    private val slots: LazyGridSlots,
     private val spans: List<TvGridItemSpan>,
     private val isVertical: Boolean,
-    private val slotsPerLine: Int,
-    private val layoutDirection: LayoutDirection,
     /**
      * Spacing to be added after [mainAxisSize], in the main axis direction.
      */
     private val mainAxisSpacing: Int,
-    private val crossAxisSpacing: Int
 ) {
     /**
      * Main axis size of the line - the max main axis size of the items on the line.
@@ -69,28 +66,23 @@
         offset: Int,
         layoutWidth: Int,
         layoutHeight: Int
-    ): List<LazyGridPositionedItem> {
-        var usedCrossAxis = 0
+    ): Array<LazyGridMeasuredItem> {
         var usedSpan = 0
-        return items.mapIndexed { itemIndex, item ->
+        items.forEachIndexed { itemIndex, item ->
             val span = spans[itemIndex].currentLineSpan
-            val startSlot = if (layoutDirection == LayoutDirection.Rtl) {
-                slotsPerLine - usedSpan - span
-            } else {
-                usedSpan
-            }
+            val startSlot = usedSpan
 
             item.position(
                 mainAxisOffset = offset,
-                crossAxisOffset = usedCrossAxis,
+                crossAxisOffset = slots.positions[startSlot],
                 layoutWidth = layoutWidth,
                 layoutHeight = layoutHeight,
-                row = if (isVertical) index.value else startSlot,
-                column = if (isVertical) startSlot else index.value
+                row = if (isVertical) index else startSlot,
+                column = if (isVertical) startSlot else index
             ).also {
-                usedCrossAxis += item.crossAxisSize + crossAxisSpacing
                 usedSpan += span
             }
         }
+        return items
     }
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt
index f713db2..c9ba570f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridScrollPosition.kt
@@ -17,11 +17,11 @@
 package androidx.tv.foundation.lazy.grid
 
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
+import androidx.tv.foundation.lazy.layout.LazyLayoutNearestRangeState
 
 /**
  * Contains the current scroll position represented by the first visible item index and the first
@@ -33,7 +33,7 @@
     initialIndex: Int = 0,
     initialScrollOffset: Int = 0
 ) {
-    var index by mutableStateOf(ItemIndex(initialIndex))
+    var index by mutableIntStateOf(initialIndex)
         private set
 
     var scrollOffset by mutableIntStateOf(initialScrollOffset)
@@ -44,6 +44,12 @@
     /** The last known key of the first item at [index] line. */
     private var lastKnownFirstItemKey: Any? = null
 
+    val nearestRangeState = LazyLayoutNearestRangeState(
+        initialIndex,
+        NearestItemsSlidingWindowSize,
+        NearestItemsExtraItemCount
+    )
+
     /**
      * Updates the current scroll position based on the results of the last measurement.
      */
@@ -56,14 +62,9 @@
             hadFirstNotEmptyLayout = true
             val scrollOffset = measureResult.firstVisibleLineScrollOffset
             check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" }
-            Snapshot.withoutReadObservation {
-                update(
-                    ItemIndex(
-                        measureResult.firstVisibleLine?.items?.firstOrNull()?.index?.value ?: 0
-                    ),
-                    scrollOffset
-                )
-            }
+
+            val firstIndex = measureResult.firstVisibleLine?.items?.firstOrNull()?.index ?: 0
+            update(firstIndex, scrollOffset)
         }
     }
 
@@ -78,7 +79,7 @@
      * c) there will be not enough items to fill the viewport after the requested index, so we
      * would have to compose few elements before the asked index, changing the first visible item.
      */
-    fun requestPosition(index: ItemIndex, scrollOffset: Int) {
+    fun requestPosition(index: Int, scrollOffset: Int) {
         update(index, scrollOffset)
         // clear the stored key as we have a direct request to scroll to [index] position and the
         // next [checkIfFirstVisibleItemWasMoved] shouldn't override this.
@@ -91,36 +92,47 @@
      * there were items added or removed before our current first visible item and keep this item
      * as the first visible one even given that its index has been changed.
      */
-    fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) {
-        Snapshot.withoutReadObservation {
-            update(
-                ItemIndex(itemProvider.findIndexByKey(lastKnownFirstItemKey, index.value)),
-                scrollOffset
-            )
+    fun updateScrollPositionIfTheFirstItemWasMoved(
+        itemProvider: LazyGridItemProvider,
+        index: Int
+    ): Int {
+        val newIndex = itemProvider.findIndexByKey(lastKnownFirstItemKey, index)
+        if (index != newIndex) {
+            this.index = newIndex
+            nearestRangeState.update(index)
         }
+        return newIndex
     }
 
-    private fun update(index: ItemIndex, scrollOffset: Int) {
-        require(index.value >= 0f) { "Index should be non-negative (${index.value})" }
-        if (index != this.index) {
-            this.index = index
-        }
-        if (scrollOffset != this.scrollOffset) {
-            this.scrollOffset = scrollOffset
-        }
+    private fun update(index: Int, scrollOffset: Int) {
+        require(index >= 0f) { "Index should be non-negative ($index)" }
+        this.index = index
+        nearestRangeState.update(index)
+        this.scrollOffset = scrollOffset
     }
 }
 
 /**
+ * We use the idea of sliding window as an optimization, so user can scroll up to this number of
+ * items until we have to regenerate the key to index map.
+ */
+private const val NearestItemsSlidingWindowSize = 90
+
+/**
+ * The minimum amount of items near the current first visible item we want to have mapping for.
+ */
+private const val NearestItemsExtraItemCount = 200
+
+/**
  * Finds a position of the item with the given key in the lists. This logic allows us to
  * detect when there were items added or removed before our current first item.
  */
 @OptIn(ExperimentalFoundationApi::class)
-internal fun LazyGridItemProvider.findIndexByKey(
+internal fun LazyLayoutItemProvider.findIndexByKey(
     key: Any?,
     lastKnownIndex: Int,
 ): Int {
-    if (key == null) {
+    if (key == null || itemCount == 0) {
         // there were no real item during the previous measure
         return lastKnownIndex
     }
@@ -130,7 +142,7 @@
         // this item is still at the same index
         return lastKnownIndex
     }
-    val newIndex = keyToIndexMap[key]
+    val newIndex = getIndex(key)
     if (newIndex != -1) {
         return newIndex
     }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
index bbe289a..5ece405 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyGridSpanLayoutProvider.kt
@@ -16,7 +16,11 @@
 
 package androidx.tv.foundation.lazy.grid
 
+import android.annotation.SuppressLint
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.lazy.layout.LazyLayoutIntervalContent
+import androidx.compose.foundation.lazy.layout.MutableIntervalList
+import androidx.compose.runtime.Composable
 import kotlin.math.min
 import kotlin.math.sqrt
 
@@ -111,7 +115,7 @@
             cachedBucket.clear()
         }
 
-        check(currentLine <= lineIndex) { "invalid currentLine" }
+        check(currentLine <= lineIndex) { "currentLine > lineIndex" }
 
         while (currentLine < lineIndex && currentItemIndex < totalSize) {
             if (cacheThisBucket) {
@@ -168,13 +172,13 @@
     /**
      * Calculate the line of index [itemIndex].
      */
-    fun getLineIndexOfItem(itemIndex: Int): LineIndex {
+    fun getLineIndexOfItem(itemIndex: Int): Int {
         if (totalSize <= 0) {
-            return LineIndex(0)
+            return 0
         }
-        require(itemIndex < totalSize) { "invalid itemIndex" }
+        require(itemIndex < totalSize) { "ItemIndex > total count" }
         if (!gridContent.hasCustomSpans) {
-            return LineIndex(itemIndex / slotsPerLine)
+            return itemIndex / slotsPerLine
         }
 
         val lowerBoundBucket = buckets.binarySearch { it.firstItemIndex - itemIndex }.let {
@@ -183,7 +187,7 @@
         var currentLine = lowerBoundBucket * bucketSize
         var currentItemIndex = buckets[lowerBoundBucket].firstItemIndex
 
-        require(currentItemIndex <= itemIndex) { "invalid currentItemIndex" }
+        require(currentItemIndex <= itemIndex) { "currentItemIndex > itemIndex" }
         var spansUsed = 0
         while (currentItemIndex < itemIndex) {
             val span = spanOf(currentItemIndex++, slotsPerLine - spansUsed)
@@ -208,7 +212,7 @@
             ++currentLine
         }
 
-        return LineIndex(currentLine)
+        return currentLine
     }
 
     fun spanOf(itemIndex: Int, maxSpan: Int): Int =
@@ -244,3 +248,71 @@
         override var maxLineSpan = 0
     }
 }
+
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyGridIntervalContent(
+    content: TvLazyGridScope.() -> Unit
+) : TvLazyGridScope, LazyLayoutIntervalContent<LazyGridInterval>() {
+    internal val spanLayoutProvider: LazyGridSpanLayoutProvider =
+        LazyGridSpanLayoutProvider(this)
+
+    override val intervals = MutableIntervalList<LazyGridInterval>()
+
+    internal var hasCustomSpans = false
+
+    init {
+        apply(content)
+    }
+
+    @SuppressLint("PrimitiveInLambda")
+    override fun item(
+        key: Any?,
+        span: (TvLazyGridItemSpanScope.() -> TvGridItemSpan)?,
+        contentType: Any?,
+        content: @Composable() (TvLazyGridItemScope.() -> Unit)
+    ) {
+        intervals.addInterval(
+            1,
+            LazyGridInterval(
+                key = key?.let { { key } },
+                span = span?.let { { span() } } ?: DefaultSpan,
+                type = { contentType },
+                item = { content() }
+            )
+        )
+        if (span != null) hasCustomSpans = true
+    }
+
+    @SuppressLint("PrimitiveInLambda")
+    override fun items(
+        count: Int,
+        key: ((index: Int) -> Any)?,
+        span: (TvLazyGridItemSpanScope.(index: Int) -> TvGridItemSpan)?,
+        contentType: (index: Int) -> Any?,
+        itemContent: @Composable() (TvLazyGridItemScope.(index: Int) -> Unit)
+    ) {
+        intervals.addInterval(
+            count,
+            LazyGridInterval(
+                key = key,
+                span = span ?: DefaultSpan,
+                type = contentType,
+                item = itemContent
+            )
+        )
+        if (span != null) hasCustomSpans = true
+    }
+
+    private companion object {
+        val DefaultSpan: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan =
+            { TvGridItemSpan(1) }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal class LazyGridInterval(
+    override val key: ((index: Int) -> Any)?,
+    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,
+    override val type: ((index: Int) -> Any?),
+    val item: @Composable TvLazyGridItemScope.(Int) -> Unit
+) : LazyLayoutIntervalContent.Interval
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyLayoutAnimateItemModifierNode.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyLayoutAnimateItemModifierNode.kt
new file mode 100644
index 0000000..6df8ebb
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyLayoutAnimateItemModifierNode.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.foundation.lazy.grid
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntOffset
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+
+internal class LazyLayoutAnimateItemModifierNode(
+    var placementAnimationSpec: FiniteAnimationSpec<IntOffset>
+) : Modifier.Node() {
+
+    /**
+     * Returns true when the placement animation is currently in progress so the parent
+     * should continue composing this item.
+     */
+    var isAnimationInProgress by mutableStateOf(false)
+        private set
+
+    /**
+     * This property is managed by the animation manager and is not directly used by this class.
+     * It represents the last known offset of this item in the lazy layout coordinate space.
+     * It will be updated on every scroll and is allowing the manager to track when the item
+     * position changes not because of the scroll event in order to start the animation.
+     * When there is an active animation it represents the final/target offset.
+     */
+    var rawOffset: IntOffset = NotInitialized
+
+    private val placementDeltaAnimation = Animatable(IntOffset.Zero, IntOffset.VectorConverter)
+
+    /**
+     * Current delta to apply for a placement offset. Updates every animation frame.
+     * The settled value is [IntOffset.Zero] so the animation is always targeting this value.
+     */
+    var placementDelta by mutableStateOf(IntOffset.Zero)
+        private set
+
+    /**
+     * Cancels the ongoing animation if there is one.
+     */
+    fun cancelAnimation() {
+        if (isAnimationInProgress) {
+            coroutineScope.launch {
+                placementDeltaAnimation.snapTo(IntOffset.Zero)
+                placementDelta = IntOffset.Zero
+                isAnimationInProgress = false
+            }
+        }
+    }
+
+    /**
+     * Tracks the offset of the item in the lookahead pass. When set, this is the animation target
+     * that placementDelta should be applied to.
+     */
+    var lookaheadOffset: IntOffset = NotInitialized
+
+    /**
+     * Animate the placement by the given [delta] offset.
+     */
+    fun animatePlacementDelta(delta: IntOffset) {
+        val totalDelta = placementDelta - delta
+        placementDelta = totalDelta
+        isAnimationInProgress = true
+        coroutineScope.launch {
+            try {
+                val spec = if (placementDeltaAnimation.isRunning) {
+                    // when interrupted, use the default spring, unless the spec is a spring.
+                    if (placementAnimationSpec is SpringSpec<IntOffset>) {
+                        placementAnimationSpec
+                    } else {
+                        InterruptionSpec
+                    }
+                } else {
+                    placementAnimationSpec
+                }
+                if (!placementDeltaAnimation.isRunning) {
+                    // if not running we can snap to the initial value and animate to zero
+                    placementDeltaAnimation.snapTo(totalDelta)
+                }
+                // if animation is not currently running the target will be zero, otherwise
+                // we have to continue the animation from the current value, but keep the needed
+                // total delta for the new animation.
+                val animationTarget = placementDeltaAnimation.value - totalDelta
+                placementDeltaAnimation.animateTo(animationTarget, spec) {
+                    // placementDelta is calculated as if we always animate to target equal to zero
+                    placementDelta = value - animationTarget
+                }
+
+                isAnimationInProgress = false
+            } catch (_: CancellationException) {
+                // we don't reset inProgress in case of cancellation as it means
+                // there is a new animation started which would reset it later
+            }
+        }
+    }
+
+    override fun onDetach() {
+        placementDelta = IntOffset.Zero
+        isAnimationInProgress = false
+        rawOffset = NotInitialized
+        // placementDeltaAnimation will be canceled because coroutineScope will be canceled.
+    }
+
+    companion object {
+        val NotInitialized = IntOffset(Int.MAX_VALUE, Int.MAX_VALUE)
+    }
+}
+
+/**
+ * We switch to this spec when a duration based animation is being interrupted.
+ */
+private val InterruptionSpec = spring(
+    stiffness = Spring.StiffnessMediumLow,
+    visibilityThreshold = IntOffset.VisibilityThreshold
+)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt
deleted file mode 100644
index aed147e..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItem.kt
+++ /dev/null
@@ -1,193 +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.tv.foundation.lazy.grid
-
-import androidx.compose.animation.core.FiniteAnimationSpec
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.util.fastForEach
-
-/**
- * Represents one measured item of the lazy grid. It can in fact consist of multiple placeables
- * if the user emit multiple layout nodes in the item callback.
- */
-internal class LazyMeasuredItem(
-    val index: ItemIndex,
-    val key: Any,
-    private val isVertical: Boolean,
-    /**
-     * Cross axis size is the same for all [placeables]. Take it as parameter for the case when
-     * [placeables] is empty.
-     */
-    val crossAxisSize: Int,
-    val mainAxisSpacing: Int,
-    private val reverseLayout: Boolean,
-    private val layoutDirection: LayoutDirection,
-    private val beforeContentPadding: Int,
-    private val afterContentPadding: Int,
-    val placeables: List<Placeable>,
-    private val placementAnimator: LazyGridItemPlacementAnimator,
-    /**
-     * The offset which shouldn't affect any calculations but needs to be applied for the final
-     * value passed into the place() call.
-     */
-    private val visualOffset: IntOffset
-) {
-    /**
-     * Main axis size of the item - the max main axis size of the placeables.
-     */
-    val mainAxisSize: Int
-
-    /**
-     * The max main axis size of the placeables plus mainAxisSpacing.
-     */
-    val mainAxisSizeWithSpacings: Int
-
-    init {
-        var maxMainAxis = 0
-        placeables.fastForEach {
-            maxMainAxis = maxOf(maxMainAxis, if (isVertical) it.height else it.width)
-        }
-        mainAxisSize = maxMainAxis
-        mainAxisSizeWithSpacings = (maxMainAxis + mainAxisSpacing).coerceAtLeast(0)
-    }
-
-    /**
-     * Calculates positions for the inner placeables at [mainAxisOffset], [crossAxisOffset].
-     * [layoutWidth] and [layoutHeight] should be provided to not place placeables which are ended
-     * up outside of the viewport (for example one item consist of 2 placeables, and the first one
-     * is not going to be visible, so we don't place it as an optimization, but place the second
-     * one). If [reverseOrder] is true the inner placeables would be placed in the inverted order.
-     */
-    fun position(
-        mainAxisOffset: Int,
-        crossAxisOffset: Int,
-        layoutWidth: Int,
-        layoutHeight: Int,
-        row: Int,
-        column: Int
-    ): LazyGridPositionedItem {
-        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
-        val crossAxisLayoutSize = if (isVertical) layoutWidth else layoutHeight
-        @Suppress("NAME_SHADOWING")
-        val crossAxisOffset = if (isVertical && layoutDirection == LayoutDirection.Rtl) {
-            crossAxisLayoutSize - crossAxisOffset - crossAxisSize
-        } else {
-            crossAxisOffset
-        }
-        return LazyGridPositionedItem(
-            offset = if (isVertical) {
-                IntOffset(crossAxisOffset, mainAxisOffset)
-            } else {
-                IntOffset(mainAxisOffset, crossAxisOffset)
-            },
-            index = index.value,
-            key = key,
-            row = row,
-            column = column,
-            size = if (isVertical) {
-                IntSize(crossAxisSize, mainAxisSize)
-            } else {
-                IntSize(mainAxisSize, crossAxisSize)
-            },
-            minMainAxisOffset = -beforeContentPadding,
-            maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding,
-            isVertical = isVertical,
-            placeables = placeables,
-            placementAnimator = placementAnimator,
-            visualOffset = visualOffset,
-            mainAxisLayoutSize = mainAxisLayoutSize,
-            reverseLayout = reverseLayout
-        )
-    }
-}
-
-internal class LazyGridPositionedItem(
-    override val offset: IntOffset,
-    override val index: Int,
-    override val key: Any,
-    override val row: Int,
-    override val column: Int,
-    override val size: IntSize,
-    private val minMainAxisOffset: Int,
-    private val maxMainAxisOffset: Int,
-    private val isVertical: Boolean,
-    private val placeables: List<Placeable>,
-    private val placementAnimator: LazyGridItemPlacementAnimator,
-    private val visualOffset: IntOffset,
-    private val mainAxisLayoutSize: Int,
-    private val reverseLayout: Boolean
-) : TvLazyGridItemInfo {
-    val placeablesCount: Int get() = placeables.size
-
-    fun getMainAxisSize(index: Int) = placeables[index].mainAxisSize
-
-    fun getMainAxisSize() = if (isVertical) size.height else size.width
-
-    fun getCrossAxisSize() = if (isVertical) size.width else size.height
-
-    fun getCrossAxisOffset() = if (isVertical) offset.x else offset.y
-
-    @Suppress("UNCHECKED_CAST")
-    fun getAnimationSpec(index: Int) =
-        placeables[index].parentData as? FiniteAnimationSpec<IntOffset>?
-
-    val hasAnimations = run {
-        repeat(placeablesCount) { index ->
-            if (getAnimationSpec(index) != null) {
-                return@run true
-            }
-        }
-        false
-    }
-
-    fun place(
-        scope: Placeable.PlacementScope,
-    ) = with(scope) {
-        repeat(placeablesCount) { index ->
-            val placeable = placeables[index]
-            val minOffset = minMainAxisOffset - placeable.mainAxisSize
-            val maxOffset = maxMainAxisOffset
-            val offset = if (getAnimationSpec(index) != null) {
-                placementAnimator.getAnimatedOffset(
-                    key, index, minOffset, maxOffset, offset
-                )
-            } else {
-                offset
-            }
-
-            val reverseLayoutAwareOffset = if (reverseLayout) {
-                offset.copy { mainAxisOffset ->
-                    mainAxisLayoutSize - mainAxisOffset - placeable.mainAxisSize
-                }
-            } else {
-                offset
-            }
-            if (isVertical) {
-                placeable.placeWithLayer(reverseLayoutAwareOffset + visualOffset)
-            } else {
-                placeable.placeRelativeWithLayer(reverseLayoutAwareOffset + visualOffset)
-            }
-        }
-    }
-
-    private val Placeable.mainAxisSize get() = if (isVertical) height else width
-    private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset =
-        IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y)
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt
deleted file mode 100644
index 7ebe5e7..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredItemProvider.kt
+++ /dev/null
@@ -1,78 +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.tv.foundation.lazy.grid
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.unit.Constraints
-import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-
-/**
- * Abstracts away the subcomposition from the measuring logic.
- */
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-@OptIn(ExperimentalFoundationApi::class)
-internal class LazyMeasuredItemProvider @ExperimentalFoundationApi constructor(
-    private val itemProvider: LazyGridItemProvider,
-    private val measureScope: LazyLayoutMeasureScope,
-    private val defaultMainAxisSpacing: Int,
-    private val measuredItemFactory: MeasuredItemFactory
-) {
-    /**
-     * Used to subcompose individual items of lazy grids. Composed placeables will be measured
-     * with the provided [constraints] and wrapped into [LazyMeasuredItem].
-     */
-    fun getAndMeasure(
-        index: ItemIndex,
-        mainAxisSpacing: Int = defaultMainAxisSpacing,
-        constraints: Constraints
-    ): LazyMeasuredItem {
-        val key = itemProvider.getKey(index.value)
-        val placeables = measureScope.measure(index.value, constraints)
-        val crossAxisSize = if (constraints.hasFixedWidth) {
-            constraints.minWidth
-        } else {
-            require(constraints.hasFixedHeight) { "constraints require fixed height" }
-            constraints.minHeight
-        }
-        return measuredItemFactory.createItem(
-            index,
-            key,
-            crossAxisSize,
-            mainAxisSpacing,
-            placeables
-        )
-    }
-
-    /**
-     * Contains the mapping between the key and the index. It could contain not all the items of
-     * the list as an optimization.
-     */
-    val keyToIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyToIndexMap
-}
-
-// This interface allows to avoid autoboxing on index param
-internal fun interface MeasuredItemFactory {
-    fun createItem(
-        index: ItemIndex,
-        key: Any,
-        crossAxisSize: Int,
-        mainAxisSpacing: Int,
-        placeables: List<Placeable>
-    ): LazyMeasuredItem
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
deleted file mode 100644
index 50cb0d3..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/LazyMeasuredLineProvider.kt
+++ /dev/null
@@ -1,108 +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.
- */
-
-package androidx.tv.foundation.lazy.grid
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.ui.unit.Constraints
-import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-
-/**
- * Abstracts away subcomposition and span calculation from the measuring logic of entire lines.
- */
-@OptIn(ExperimentalFoundationApi::class)
-internal class LazyMeasuredLineProvider(
-    private val isVertical: Boolean,
-    private val slotSizesSums: List<Int>,
-    private val crossAxisSpacing: Int,
-    private val gridItemsCount: Int,
-    private val spaceBetweenLines: Int,
-    private val measuredItemProvider: LazyMeasuredItemProvider,
-    private val spanLayoutProvider: LazyGridSpanLayoutProvider,
-    private val measuredLineFactory: MeasuredLineFactory
-) {
-    // The constraints for cross axis size. The main axis is not restricted.
-    internal fun childConstraints(startSlot: Int, span: Int): Constraints {
-        val lastSlotSum = slotSizesSums[startSlot + span - 1]
-        val prevSlotSum = if (startSlot == 0) 0 else slotSizesSums[startSlot - 1]
-        val slotsSize = lastSlotSum - prevSlotSum
-        val crossAxisSize = (slotsSize + crossAxisSpacing * (span - 1)).coerceAtLeast(0)
-        return if (isVertical) {
-            Constraints.fixedWidth(crossAxisSize)
-        } else {
-            Constraints.fixedHeight(crossAxisSize)
-        }
-    }
-
-    fun itemConstraints(itemIndex: ItemIndex): Constraints {
-        val span = spanLayoutProvider.spanOf(
-            itemIndex.value,
-            spanLayoutProvider.slotsPerLine
-        )
-        return childConstraints(0, span)
-    }
-
-    /**
-     * Used to subcompose items on lines of lazy grids. Composed placeables will be measured
-     * with the correct constraints and wrapped into [LazyMeasuredLine].
-     */
-    fun getAndMeasure(lineIndex: LineIndex): LazyMeasuredLine {
-        val lineConfiguration = spanLayoutProvider.getLineConfiguration(lineIndex.value)
-        val lineItemsCount = lineConfiguration.spans.size
-
-        // we add space between lines as an extra spacing for all lines apart from the last one
-        // so the lazy grid measuring logic will take it into account.
-        val mainAxisSpacing = if (lineItemsCount == 0 ||
-            lineConfiguration.firstItemIndex + lineItemsCount == gridItemsCount) {
-            0
-        } else {
-            spaceBetweenLines
-        }
-
-        var startSlot = 0
-        val items = Array(lineItemsCount) {
-            val span = lineConfiguration.spans[it].currentLineSpan
-            val constraints = childConstraints(startSlot, span)
-            measuredItemProvider.getAndMeasure(
-                ItemIndex(lineConfiguration.firstItemIndex + it),
-                mainAxisSpacing,
-                constraints
-            ).also { startSlot += span }
-        }
-        return measuredLineFactory.createLine(
-            lineIndex,
-            items,
-            lineConfiguration.spans,
-            mainAxisSpacing
-        )
-    }
-
-    /**
-     * Contains the mapping between the key and the index. It could contain not all the items of
-     * the list as an optimization.
-     */
-    val keyToIndexMap: LazyLayoutKeyIndexMap get() = measuredItemProvider.keyToIndexMap
-}
-
-// This interface allows to avoid autoboxing on index param
-internal fun interface MeasuredLineFactory {
-    fun createLine(
-        index: LineIndex,
-        items: Array<LazyMeasuredItem>,
-        spans: List<TvGridItemSpan>,
-        mainAxisSpacing: Int
-    ): LazyMeasuredLine
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt
index 2bcedda..0c6cff1 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemInfo.kt
@@ -60,6 +60,11 @@
      */
     val size: IntSize
 
+    /**
+     * The content type of the item which was passed to the item() or items() function.
+     */
+    val contentType: Any?
+
     companion object {
         /**
          * Possible value for [row], when they are unknown. This can happen when the item is
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt
index 35d063f..82ddfdb 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridItemScopeImpl.kt
@@ -19,10 +19,10 @@
 import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.ParentDataModifier
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.ParentDataModifierNode
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.InspectorValueInfo
-import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 
@@ -31,25 +31,40 @@
 internal object TvLazyGridItemScopeImpl : TvLazyGridItemScope {
     @ExperimentalFoundationApi
     override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec<IntOffset>) =
-        this.then(AnimateItemPlacementModifier(animationSpec, debugInspectorInfo {
-            name = "animateItemPlacement"
-            value = animationSpec
-        }))
+        this then AnimateItemPlacementElement(animationSpec)
 }
 
-private class AnimateItemPlacementModifier(
-    val animationSpec: FiniteAnimationSpec<IntOffset>,
-    inspectorInfo: InspectorInfo.() -> Unit,
-) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
-    override fun Density.modifyParentData(parentData: Any?): Any = animationSpec
+private class AnimateItemPlacementElement(
+    val animationSpec: FiniteAnimationSpec<IntOffset>
+) : ModifierNodeElement<AnimateItemPlacementNode>() {
+
+    override fun create(): AnimateItemPlacementNode = AnimateItemPlacementNode(animationSpec)
+
+    override fun update(node: AnimateItemPlacementNode) {
+        node.delegatingNode.placementAnimationSpec = animationSpec
+    }
 
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
-        if (other !is AnimateItemPlacementModifier) return false
+        if (other !is AnimateItemPlacementElement) return false
         return animationSpec != other.animationSpec
     }
 
     override fun hashCode(): Int {
         return animationSpec.hashCode()
     }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "animateItemPlacement"
+        value = animationSpec
+    }
+}
+
+private class AnimateItemPlacementNode(
+    animationSpec: FiniteAnimationSpec<IntOffset>
+) : DelegatingNode(), ParentDataModifierNode {
+
+    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(animationSpec))
+
+    override fun Density.modifyParentData(parentData: Any?): Any = delegatingNode
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt
index e3bafd7..1f75f73 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridMeasureResult.kt
@@ -28,7 +28,7 @@
 internal class TvLazyGridMeasureResult(
     // properties defining the scroll position:
     /** The new first visible line of items.*/
-    val firstVisibleLine: LazyMeasuredLine?,
+    val firstVisibleLine: LazyGridMeasuredLine?,
     /** The new value for [TvLazyGridState.firstVisibleItemScrollOffset].*/
     val firstVisibleLineScrollOffset: Int,
     /** True if there is some space available to continue scrolling in the forward direction.*/
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt
deleted file mode 100644
index 06e746f..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridScopeImpl.kt
+++ /dev/null
@@ -1,87 +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.
- */
-
-package androidx.tv.foundation.lazy.grid
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.lazy.layout.LazyLayoutIntervalContent
-import androidx.compose.foundation.lazy.layout.MutableIntervalList
-import androidx.compose.runtime.Composable
-
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-@OptIn(ExperimentalFoundationApi::class)
-internal class LazyGridIntervalContent(
-    content: TvLazyGridScope.() -> Unit
-) : TvLazyGridScope, LazyLayoutIntervalContent<LazyGridInterval>() {
-    internal val spanLayoutProvider: LazyGridSpanLayoutProvider = LazyGridSpanLayoutProvider(this)
-
-    override val intervals = MutableIntervalList<LazyGridInterval>()
-
-    internal var hasCustomSpans = false
-
-    init {
-        apply(content)
-    }
-
-    override fun item(
-        key: Any?,
-        span: (TvLazyGridItemSpanScope.() -> TvGridItemSpan)?,
-        contentType: Any?,
-        content: @Composable TvLazyGridItemScope.() -> Unit
-    ) {
-        intervals.addInterval(
-            1,
-            LazyGridInterval(
-                key = key?.let { { key } },
-                span = span?.let { { span() } } ?: DefaultSpan,
-                type = { contentType },
-                item = { content() }
-            )
-        )
-        if (span != null) hasCustomSpans = true
-    }
-
-    override fun items(
-        count: Int,
-        key: ((index: Int) -> Any)?,
-        span: (TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan)?,
-        contentType: (index: Int) -> Any?,
-        itemContent: @Composable TvLazyGridItemScope.(index: Int) -> Unit
-    ) {
-        intervals.addInterval(
-            count,
-            LazyGridInterval(
-                key = key,
-                span = span ?: DefaultSpan,
-                type = contentType,
-                item = itemContent
-            )
-        )
-        if (span != null) hasCustomSpans = true
-    }
-
-    private companion object {
-        val DefaultSpan: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan = { TvGridItemSpan(1) }
-    }
-}
-
-@OptIn(ExperimentalFoundationApi::class)
-internal class LazyGridInterval(
-    override val key: ((index: Int) -> Any)?,
-    val span: TvLazyGridItemSpanScope.(Int) -> TvGridItemSpan,
-    override val type: ((index: Int) -> Any?),
-    val item: @Composable TvLazyGridItemScope.(Int) -> Unit
-) : LazyLayoutIntervalContent.Interval
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
index 037afba..000d89a 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/grid/TvLazyGridState.kt
@@ -35,14 +35,16 @@
 import androidx.compose.runtime.saveable.listSaver
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.layout.Remeasurement
 import androidx.compose.ui.layout.RemeasurementModifier
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.util.fastForEach
+import androidx.tv.foundation.lazy.layout.AwaitFirstLayoutModifier
+import androidx.tv.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
 import androidx.tv.foundation.lazy.layout.animateScrollToItem
-import androidx.tv.foundation.lazy.list.AwaitFirstLayoutModifier
 import kotlin.math.abs
 
 /**
@@ -95,7 +97,7 @@
      * Note that this property is observable and if you use it in the composable function it will
      * be recomposed on every change causing potential performance issues.
      */
-    val firstVisibleItemIndex: Int get() = scrollPosition.index.value
+    val firstVisibleItemIndex: Int get() = scrollPosition.index
 
     /**
      * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the
@@ -141,12 +143,12 @@
     /**
      * Needed for [animateScrollToItem]. Updated on every measure.
      */
-    internal var density: Density by mutableStateOf(Density(1f, 1f))
+    internal var density: Density = Density(1f, 1f)
 
     /**
      * Needed for [notifyPrefetch].
      */
-    internal var isVertical: Boolean by mutableStateOf(true)
+    internal var isVertical: Boolean = true
 
     /**
      * The ScrollableController instance. We keep it as we need to call stopAnimation on it once
@@ -188,7 +190,7 @@
      * The [Remeasurement] object associated with our layout. It allows us to remeasure
      * synchronously during scroll.
      */
-    private var remeasurement: Remeasurement? by mutableStateOf(null)
+    internal var remeasurement: Remeasurement? = null
 
     /**
      * The modifier which provides [remeasurement].
@@ -208,10 +210,12 @@
     /**
      * Finds items on a line and their measurement constraints. Used for prefetching.
      */
-    internal var prefetchInfoRetriever: (line: LineIndex) -> List<Pair<Int, Constraints>> by
+    internal var prefetchInfoRetriever: (line: Int) -> List<Pair<Int, Constraints>> by
     mutableStateOf({ emptyList() })
 
-    internal var placementAnimator by mutableStateOf<LazyGridItemPlacementAnimator?>(null)
+    internal val placementAnimator = LazyGridItemPlacementAnimator()
+
+    internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
 
     private val animateScrollScope = LazyGridAnimateScrollScope(this)
 
@@ -220,6 +224,8 @@
      */
     internal val pinnedItems = LazyLayoutPinnedItemList()
 
+    internal val nearestRange: IntRange by scrollPosition.nearestRangeState
+
     /**
      * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
      * pixels.
@@ -240,9 +246,9 @@
     }
 
     internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
-        scrollPosition.requestPosition(ItemIndex(index), scrollOffset)
+        scrollPosition.requestPosition(index, scrollOffset)
         // placement animation is not needed because we snap into a new position.
-        placementAnimator?.reset()
+        placementAnimator.reset()
         remeasurement?.forceRemeasure()
     }
 
@@ -344,7 +350,7 @@
                 this.wasScrollingForward = scrollingForward
                 this.lineToPrefetch = lineToPrefetch
                 currentLinePrefetchHandles.clear()
-                prefetchInfoRetriever(LineIndex(lineToPrefetch)).fastForEach {
+                prefetchInfoRetriever(lineToPrefetch).fastForEach {
                     currentLinePrefetchHandles.add(
                         prefetchState.schedulePrefetch(it.first, it.second)
                     )
@@ -399,8 +405,8 @@
         layoutInfoState.value = result
 
         canScrollForward = result.canScrollForward
-        canScrollBackward = (result.firstVisibleLine?.index?.value ?: 0) != 0 ||
-            result.firstVisibleLineScrollOffset != 0
+        canScrollBackward =
+            (result.firstVisibleLine?.index ?: 0) != 0 || result.firstVisibleLineScrollOffset != 0
 
         numMeasurePasses++
 
@@ -412,9 +418,10 @@
      * items added or removed before our current first visible item and keep this item
      * as the first visible one even given that its index has been changed.
      */
-    internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyGridItemProvider) {
-        scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
-    }
+    internal fun updateScrollPositionIfTheFirstItemWasMoved(
+        itemProvider: LazyGridItemProvider,
+        firstItemIndex: Int = Snapshot.withoutReadObservation { scrollPosition.index }
+    ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
 
     companion object {
         /**
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/AwaitFirstLayoutModifier.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/AwaitFirstLayoutModifier.kt
new file mode 100644
index 0000000..acf6806
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/AwaitFirstLayoutModifier.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.foundation.lazy.layout
+
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.OnGloballyPositionedModifier
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+/**
+ * Internal modifier which allows to delay some interactions (e.g. scroll) until layout is ready.
+ */
+internal class AwaitFirstLayoutModifier : OnGloballyPositionedModifier {
+    private var wasPositioned = false
+    private var continuation: Continuation<Unit>? = null
+
+    suspend fun waitForFirstLayout() {
+        if (!wasPositioned) {
+            val oldContinuation = continuation
+            suspendCoroutine { continuation = it }
+            oldContinuation?.resume(Unit)
+        }
+    }
+
+    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
+        if (!wasPositioned) {
+            wasPositioned = true
+            continuation?.resume(Unit)
+            continuation = null
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutBeyondBoundsInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutBeyondBoundsInfo.kt
new file mode 100644
index 0000000..b99b488
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutBeyondBoundsInfo.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.tv.foundation.lazy.layout
+
+import androidx.compose.runtime.collection.mutableVectorOf
+
+/**
+ * This data structure is used to save information about the number of "beyond bounds items"
+ * that we want to compose. These items are not within the visible bounds of the lazy layout,
+ * but we compose them because they are explicitly requested through the
+ * [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout].
+ *
+ * When the lazy layout receives a
+ * [layout][androidx.compose.ui.layout.BeyondBoundsLayout.layout] request to layout items beyond
+ * visible bounds, it creates an instance of [LazyLayoutBeyondBoundsInfo.Interval] by using the
+ * [addInterval] function.
+ * This returns the interval of items that are currently composed, and we can request other
+ * intervals to control the number of beyond bounds items.
+ *
+ * There can be multiple intervals created at the same time, and [LazyLayoutBeyondBoundsInfo] merges
+ * all the intervals to calculate the effective beyond bounds items.
+ *
+ * The [beyond bounds layout API][androidx.compose.ui.layout.BeyondBoundsLayout] is designed to be
+ * synchronous, so once you are done using the items, call [removeInterval] to remove
+ * the extra items you had requested.
+ *
+ * Note that when you clear an interval, the items in that interval might not be cleared right
+ * away if another interval was created that has the same items. This is done to support two use
+ * cases:
+ *
+ * 1. To allow items to be pinned while they are being scrolled into view.
+ *
+ * 2. To allow users to call [layout][androidx.compose.ui.layout.BeyondBoundsLayout.layout] from
+ * within the completion block of another layout call.
+ */
+internal class LazyLayoutBeyondBoundsInfo {
+    private val beyondBoundsItems = mutableVectorOf<Interval>()
+
+    /**
+     * Create a beyond bounds interval. This can be used to specify which composed items we want to
+     * retain. For instance, it can be used to force the measuring of items that are beyond the
+     * visible bounds of a lazy list.
+     *
+     * @param start The starting index (inclusive) for this interval.
+     * @param end The ending index (inclusive) for this interval.
+     *
+     * @return An interval that specifies which items we want to retain.
+     */
+    fun addInterval(start: Int, end: Int): Interval {
+        return Interval(start, end).apply {
+            beyondBoundsItems.add(this)
+        }
+    }
+
+    /**
+     * Clears the specified interval. Use this to remove the interval created by [addInterval].
+     */
+    fun removeInterval(interval: Interval) {
+        beyondBoundsItems.remove(interval)
+    }
+
+    /**
+     * Returns true if there are beyond bounds intervals.
+     */
+    fun hasIntervals(): Boolean = beyondBoundsItems.isNotEmpty()
+
+    /**
+     *  The effective start index after merging all the current intervals.
+     */
+    val start: Int
+        get() {
+            var minIndex = beyondBoundsItems.first().start
+            beyondBoundsItems.forEach {
+                if (it.start < minIndex) {
+                    minIndex = it.start
+                }
+            }
+            require(minIndex >= 0) { "negative minIndex" }
+            return minIndex
+        }
+
+    /**
+     *  The effective end index after merging all the current intervals.
+     */
+    val end: Int
+        get() {
+            var maxIndex = beyondBoundsItems.first().end
+            beyondBoundsItems.forEach {
+                if (it.end > maxIndex) {
+                    maxIndex = it.end
+                }
+            }
+            return maxIndex
+        }
+
+    /**
+     * The Interval used to implement [LazyLayoutBeyondBoundsInfo].
+     */
+    internal data class Interval(
+        /** The start index for the interval. */
+        val start: Int,
+
+        /** The end index for the interval. */
+        val end: Int
+    ) {
+        init {
+            require(start >= 0) { "negative start index" }
+            require(end >= start) { "end index greater than start" }
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt
index 4e92dc5..fdabf2f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutKeyIndexMap.kt
@@ -17,14 +17,9 @@
 package androidx.tv.foundation.lazy.layout
 
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.lazy.layout.IntervalList
 import androidx.compose.foundation.lazy.layout.LazyLayoutIntervalContent
 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
-import androidx.compose.runtime.State
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.referentialEqualityPolicy
-import androidx.compose.runtime.structuralEqualityPolicy
+import androidx.compose.foundation.lazy.layout.getDefaultLazyLayoutKey
 
 /**
  * A key-index mapping used inside the [LazyLayoutItemProvider]. It might not contain all items
@@ -36,52 +31,20 @@
     /**
      * @return current index for given [key] or `-1` if not found.
      */
-    operator fun get(key: Any): Int
+    fun getIndex(key: Any): Int
+
+    /**
+     * @return key for a given [index] if it is known, or null otherwise.
+     */
+    fun getKey(index: Int): Any?
 
     /**
      * Empty map implementation, always returning `-1` for any key.
      */
     companion object Empty : LazyLayoutKeyIndexMap {
         @Suppress("AutoBoxing")
-        override fun get(key: Any): Int = -1
-    }
-}
-
-/**
- * State containing [LazyLayoutKeyIndexMap] precalculated for range of indexes near first visible
- * item.
- * It is optimized to return the same range for small changes in the firstVisibleItemIndex
- * value so we do not regenerate the map on each scroll.
- *
- * @param firstVisibleItemIndex Provider of the first item index currently visible on screen.
- * @param slidingWindowSize Number of items between current and `firstVisibleItem` until
- * [LazyLayoutKeyIndexMap] is regenerated.
- * @param extraItemCount The minimum amount of items in one direction near the first visible item
- * to calculate mapping for.
- * @param content Provider of [LazyLayoutIntervalContent] to generate key index mapping for.
- */
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-@ExperimentalFoundationApi
-internal class NearestRangeKeyIndexMapState(
-    firstVisibleItemIndex: () -> Int,
-    slidingWindowSize: () -> Int,
-    extraItemCount: () -> Int,
-    content: () -> LazyLayoutIntervalContent<*>
-) : State<LazyLayoutKeyIndexMap> {
-    private val nearestRangeState by derivedStateOf(structuralEqualityPolicy()) {
-        if (content().itemCount < extraItemCount() * 2 + slidingWindowSize()) {
-            0 until content().itemCount
-        } else {
-            calculateNearestItemsRange(
-                firstVisibleItemIndex(),
-                slidingWindowSize(),
-                extraItemCount()
-            )
-        }
-    }
-
-    override val value: LazyLayoutKeyIndexMap by derivedStateOf(referentialEqualityPolicy()) {
-        NearestRangeKeyIndexMap(nearestRangeState, content())
+        override fun getIndex(key: Any): Int = -1
+        override fun getKey(index: Int) = null
     }
 }
 
@@ -91,48 +54,51 @@
  */
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @ExperimentalFoundationApi
-private class NearestRangeKeyIndexMap(
+internal class NearestRangeKeyIndexMap(
     nearestRange: IntRange,
-    content: LazyLayoutIntervalContent<*>
+    intervalContent: LazyLayoutIntervalContent<*>
 ) : LazyLayoutKeyIndexMap {
-    private val map = generateKeyToIndexMap(nearestRange, content.intervals)
+    private val map: Map<Any, Int>
+    private val keys: Array<Any?>
+    private val keysStartIndex: Int
 
-    override fun get(key: Any): Int = map.getOrElse(key) { -1 }
-
-    companion object {
-        /**
-         * Traverses the interval [list] in order to create a mapping from the key to the index for all
-         * the indexes in the passed [range].
-         * The returned map will not contain the values for intervals with no key mapping provided.
-         */
-        private fun generateKeyToIndexMap(
-            range: IntRange,
-            list: IntervalList<LazyLayoutIntervalContent.Interval>
-        ): Map<Any, Int> {
-            val first = range.first
-            check(first >= 0) { "Invalid start of range" }
-            val last = minOf(range.last, list.size - 1)
-            return if (last < first) {
-                emptyMap()
-            } else {
-                hashMapOf<Any, Int>().also { map ->
-                    list.forEach(
-                        fromIndex = first,
-                        toIndex = last,
-                    ) {
-                        if (it.value.key != null) {
-                            val keyFactory = requireNotNull(it.value.key)
-                            val start = maxOf(first, it.startIndex)
-                            val end = minOf(last, it.startIndex + it.size - 1)
-                            for (i in start..end) {
-                                map[keyFactory(i - it.startIndex)] = i
-                            }
-                        }
+    init {
+        // Traverses the interval [list] in order to create a mapping from the key to the index for
+        // all the indexes in the passed [range].
+        val list = intervalContent.intervals
+        val first = nearestRange.first
+        check(first >= 0) { "negative nearestRange.first" }
+        val last = minOf(nearestRange.last, list.size - 1)
+        if (last < first) {
+            map = emptyMap()
+            keys = emptyArray()
+            keysStartIndex = 0
+        } else {
+            keys = arrayOfNulls<Any?>(last - first + 1)
+            keysStartIndex = first
+            map = hashMapOf<Any, Int>().also { map ->
+                list.forEach(
+                    fromIndex = first,
+                    toIndex = last,
+                ) {
+                    val keyFactory = it.value.key
+                    val start = maxOf(first, it.startIndex)
+                    val end = minOf(last, it.startIndex + it.size - 1)
+                    for (i in start..end) {
+                        val key =
+                            keyFactory?.invoke(i - it.startIndex) ?: getDefaultLazyLayoutKey(i)
+                        map[key] = i
+                        keys[i - keysStartIndex] = key
                     }
                 }
             }
         }
     }
+
+    override fun getIndex(key: Any): Int = map.getOrElse(key) { -1 }
+
+    override fun getKey(index: Int) =
+        keys.getOrElse(index - keysStartIndex) { null }
 }
 
 /**
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutNearestRangeState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutNearestRangeState.kt
new file mode 100644
index 0000000..cdfcbb6
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutNearestRangeState.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.tv.foundation.lazy.layout
+
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.structuralEqualityPolicy
+
+internal class LazyLayoutNearestRangeState(
+    firstVisibleItem: Int,
+    private val slidingWindowSize: Int,
+    private val extraItemCount: Int
+) : State<IntRange> {
+
+    override var value: IntRange by mutableStateOf(
+        calculateNearestItemsRange(firstVisibleItem, slidingWindowSize, extraItemCount),
+        structuralEqualityPolicy()
+    )
+        private set
+
+    private var lastFirstVisibleItem = firstVisibleItem
+
+    fun update(firstVisibleItem: Int) {
+        if (firstVisibleItem != lastFirstVisibleItem) {
+            lastFirstVisibleItem = firstVisibleItem
+            value = calculateNearestItemsRange(firstVisibleItem, slidingWindowSize, extraItemCount)
+        }
+    }
+
+    private companion object {
+        /**
+         * Returns a range of indexes which contains at least [extraItemCount] items near
+         * the first visible item. It is optimized to return the same range for small changes in the
+         * firstVisibleItem value so we do not regenerate the map on each scroll.
+         */
+        private fun calculateNearestItemsRange(
+            firstVisibleItem: Int,
+            slidingWindowSize: Int,
+            extraItemCount: Int
+        ): IntRange {
+            val slidingWindowStart = slidingWindowSize * (firstVisibleItem / slidingWindowSize)
+
+            val start = maxOf(slidingWindowStart - extraItemCount, 0)
+            val end = slidingWindowStart + slidingWindowSize + extraItemCount
+            return start until end
+        }
+    }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.kt
deleted file mode 100644
index 90374c3..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemanticState.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.tv.foundation.lazy.layout
-
-import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.ui.semantics.CollectionInfo
-import androidx.tv.foundation.lazy.list.TvLazyListState
-
-internal fun LazyLayoutSemanticState(
-    state: TvLazyListState,
-    isVertical: Boolean
-): LazyLayoutSemanticState = object : LazyLayoutSemanticState {
-
-    override val currentPosition: Float
-        get() = state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
-    override val canScrollForward: Boolean
-        get() = state.canScrollForward
-
-    override suspend fun animateScrollBy(delta: Float) {
-        state.animateScrollBy(delta)
-    }
-
-    override suspend fun scrollToItem(index: Int) {
-        state.scrollToItem(index)
-    }
-
-    override fun collectionInfo(): CollectionInfo =
-        if (isVertical) {
-            CollectionInfo(rowCount = -1, columnCount = 1)
-        } else {
-            CollectionInfo(rowCount = 1, columnCount = -1)
-        }
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
index 5058a26..181e874 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/layout/LazyLayoutSemantics.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
@@ -28,17 +29,19 @@
 import androidx.compose.ui.semantics.collectionInfo
 import androidx.compose.ui.semantics.horizontalScrollAxisRange
 import androidx.compose.ui.semantics.indexForKey
+import androidx.compose.ui.semantics.isTraversalGroup
 import androidx.compose.ui.semantics.scrollBy
 import androidx.compose.ui.semantics.scrollToIndex
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.verticalScrollAxisRange
+import androidx.tv.foundation.lazy.list.TvLazyListState
 import kotlinx.coroutines.launch
 
 @OptIn(ExperimentalFoundationApi::class)
 @Suppress("ComposableModifierFactory")
 @Composable
 internal fun Modifier.lazyLayoutSemantics(
-    itemProvider: LazyLayoutItemProvider,
+    itemProviderLambda: () -> LazyLayoutItemProvider,
     state: LazyLayoutSemanticState,
     orientation: Orientation,
     userScrollEnabled: Boolean,
@@ -47,13 +50,14 @@
     val coroutineScope = rememberCoroutineScope()
     return this.then(
         remember(
-            itemProvider,
+            itemProviderLambda,
             state,
             orientation,
             userScrollEnabled
         ) {
             val isVertical = orientation == Orientation.Vertical
             val indexForKeyMapping: (Any) -> Int = { needle ->
+                val itemProvider = itemProviderLambda()
                 var result = -1
                 for (index in 0 until itemProvider.itemCount) {
                     if (itemProvider.getKey(index) == needle) {
@@ -73,6 +77,7 @@
                     state.currentPosition
                 },
                 maxValue = {
+                    val itemProvider = itemProviderLambda()
                     if (state.canScrollForward) {
                         // If we can scroll further, we don't know the end yet,
                         // but it's upper bounded by #items + 1
@@ -104,6 +109,7 @@
 
             val scrollToIndexAction: ((Int) -> Boolean)? = if (userScrollEnabled) {
                 { index ->
+                    val itemProvider = itemProviderLambda()
                     require(index >= 0 && index < itemProvider.itemCount) {
                         "Can't scroll to index $index, it is out of " +
                             "bounds [0, ${itemProvider.itemCount})"
@@ -120,6 +126,7 @@
             val collectionInfo = state.collectionInfo()
 
             Modifier.semantics {
+                isTraversalGroup = true
                 indexForKey(indexForKeyMapping)
 
                 if (isVertical) {
@@ -149,3 +156,29 @@
     suspend fun animateScrollBy(delta: Float)
     suspend fun scrollToItem(index: Int)
 }
+
+internal fun LazyLayoutSemanticState(
+    state: TvLazyListState,
+    isVertical: Boolean
+): LazyLayoutSemanticState = object : LazyLayoutSemanticState {
+
+    override val currentPosition: Float
+        get() = state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset / 100_000f
+    override val canScrollForward: Boolean
+        get() = state.canScrollForward
+
+    override suspend fun animateScrollBy(delta: Float) {
+        state.animateScrollBy(delta)
+    }
+
+    override suspend fun scrollToItem(index: Int) {
+        state.scrollToItem(index)
+    }
+
+    override fun collectionInfo(): CollectionInfo =
+        if (isVertical) {
+            CollectionInfo(rowCount = -1, columnCount = 1)
+        } else {
+            CollectionInfo(rowCount = 1, columnCount = -1)
+        }
+}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt
deleted file mode 100644
index 7480db2..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/DataIndex.kt
+++ /dev/null
@@ -1,31 +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.tv.foundation.lazy.list
-
-/**
- * Represents an index in the list of items of lazy layout.
- */
-@Suppress("NOTHING_TO_INLINE")
-@kotlin.jvm.JvmInline
-internal value class DataIndex(val value: Int) {
-    inline operator fun inc(): DataIndex = DataIndex(value + 1)
-    inline operator fun dec(): DataIndex = DataIndex(value - 1)
-    inline operator fun plus(i: Int): DataIndex = DataIndex(value + i)
-    inline operator fun minus(i: Int): DataIndex = DataIndex(value - i)
-    inline operator fun minus(i: DataIndex): DataIndex = DataIndex(value - i.value)
-    inline operator fun compareTo(other: DataIndex): Int = value - other.value
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyBeyondBoundsModifier.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyBeyondBoundsModifier.kt
index 38d1440..3dce438 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyBeyondBoundsModifier.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyBeyondBoundsModifier.kt
@@ -16,7 +16,10 @@
 
 package androidx.tv.foundation.lazy.list
 
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
+import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
@@ -35,7 +38,9 @@
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.LayoutDirection.Ltr
 import androidx.compose.ui.unit.LayoutDirection.Rtl
-import androidx.tv.foundation.lazy.list.LazyListBeyondBoundsInfo.Interval
+import androidx.compose.ui.util.fastForEach
+import androidx.tv.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
+import kotlin.math.min
 
 /**
  * This modifier is used to measure and place additional items when the lazyList receives a
@@ -45,20 +50,24 @@
 @Composable
 internal fun Modifier.lazyListBeyondBoundsModifier(
     state: TvLazyListState,
-    beyondBoundsInfo: LazyListBeyondBoundsInfo,
+    beyondBoundsItemCount: Int,
     reverseLayout: Boolean,
     orientation: Orientation
 ): Modifier {
     val layoutDirection = LocalLayoutDirection.current
+    val beyondBoundsState = remember(state, beyondBoundsItemCount) {
+        LazyListBeyondBoundsState(state, beyondBoundsItemCount)
+    }
+    val beyondBoundsInfo = state.beyondBoundsInfo
     return this then remember(
-        state,
+        beyondBoundsState,
         beyondBoundsInfo,
         reverseLayout,
         layoutDirection,
         orientation
     ) {
-        LazyListBeyondBoundsModifierLocal(
-            state,
+        LazyLayoutBeyondBoundsModifierLocal(
+            beyondBoundsState,
             beyondBoundsInfo,
             reverseLayout,
             layoutDirection,
@@ -67,9 +76,71 @@
     }
 }
 
-private class LazyListBeyondBoundsModifierLocal(
-    private val state: TvLazyListState,
-    private val beyondBoundsInfo: LazyListBeyondBoundsInfo,
+internal class LazyListBeyondBoundsState(
+    val state: TvLazyListState,
+    val beyondBoundsItemCount: Int
+) : LazyLayoutBeyondBoundsState {
+
+    override fun remeasure() {
+        state.remeasurement?.forceRemeasure()
+    }
+
+    override val itemCount: Int
+        get() = state.layoutInfo.totalItemsCount
+    override val hasVisibleItems: Boolean
+        get() = state.layoutInfo.visibleItemsInfo.isNotEmpty()
+    override val firstPlacedIndex: Int
+        get() = maxOf(0, state.firstVisibleItemIndex - beyondBoundsItemCount)
+    override val lastPlacedIndex: Int
+        get() = minOf(
+            itemCount - 1,
+            state.layoutInfo.visibleItemsInfo.last().index + beyondBoundsItemCount
+        )
+}
+
+internal interface LazyLayoutBeyondBoundsState {
+
+    fun remeasure()
+
+    val itemCount: Int
+
+    val hasVisibleItems: Boolean
+
+    val firstPlacedIndex: Int
+
+    val lastPlacedIndex: Int
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal fun LazyLayoutItemProvider.calculateLazyLayoutPinnedIndices(
+    pinnedItemList: LazyLayoutPinnedItemList,
+    beyondBoundsInfo: LazyLayoutBeyondBoundsInfo,
+): List<Int> {
+    if (!beyondBoundsInfo.hasIntervals() && pinnedItemList.isEmpty()) {
+        return emptyList()
+    } else {
+        val pinnedItems = mutableListOf<Int>()
+        val beyondBoundsRange = if (beyondBoundsInfo.hasIntervals()) {
+            beyondBoundsInfo.start..min(beyondBoundsInfo.end, itemCount - 1)
+        } else {
+            IntRange.EMPTY
+        }
+        pinnedItemList.fastForEach {
+            val index = findIndexByKey(it.key, it.index)
+            if (index in beyondBoundsRange) return@fastForEach
+            if (index !in 0 until itemCount) return@fastForEach
+            pinnedItems.add(index)
+        }
+        for (i in beyondBoundsRange) {
+            pinnedItems.add(i)
+        }
+        return pinnedItems
+    }
+}
+
+internal class LazyLayoutBeyondBoundsModifierLocal(
+    private val state: LazyLayoutBeyondBoundsState,
+    private val beyondBoundsInfo: LazyLayoutBeyondBoundsInfo,
     private val reverseLayout: Boolean,
     private val layoutDirection: LayoutDirection,
     private val orientation: Orientation
@@ -90,16 +161,17 @@
     ): T? {
         // If the lazy list is empty, or if it does not have any visible items (Which implies
         // that there isn't space to add a single item), we don't attempt to layout any more items.
-        if (state.layoutInfo.totalItemsCount <= 0 || state.layoutInfo.visibleItemsInfo.isEmpty()) {
+        if (state.itemCount <= 0 || !state.hasVisibleItems) {
             return block.invoke(emptyBeyondBoundsScope)
         }
 
         // We use a new interval each time because this function is re-entrant.
-        var interval = beyondBoundsInfo.addInterval(
-            state.firstVisibleItemIndex,
-            state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: state.firstVisibleItemIndex
-        )
-
+        val startIndex = if (direction.isForward()) {
+            state.lastPlacedIndex
+        } else {
+            state.firstPlacedIndex
+        }
+        var interval = beyondBoundsInfo.addInterval(startIndex, startIndex)
         var found: T? = null
         while (found == null && interval.hasMoreContent(direction)) {
 
@@ -107,7 +179,7 @@
             interval = addNextInterval(interval, direction).also {
                 beyondBoundsInfo.removeInterval(interval)
             }
-            state.remeasurement?.forceRemeasure()
+            state.remeasure()
 
             // When we invoke this block, the beyond bounds items are present.
             found = block.invoke(
@@ -120,53 +192,46 @@
 
         // Dispose the items that are beyond the visible bounds.
         beyondBoundsInfo.removeInterval(interval)
-        state.remeasurement?.forceRemeasure()
+        state.remeasure()
         return found
     }
 
-    private fun addNextInterval(
-        currentInterval: Interval,
-        direction: BeyondBoundsLayout.LayoutDirection
-    ): Interval {
-        var start = currentInterval.start
-        var end = currentInterval.end
-        when (direction) {
-            Before -> start--
-            After -> end++
-            Above -> if (reverseLayout) end++ else start--
-            Below -> if (reverseLayout) start-- else end++
+    private fun BeyondBoundsLayout.LayoutDirection.isForward(): Boolean =
+        when (this) {
+            Before -> false
+            After -> true
+            Above -> reverseLayout
+            Below -> !reverseLayout
             Left -> when (layoutDirection) {
-                Ltr -> if (reverseLayout) end++ else start--
-                Rtl -> if (reverseLayout) start-- else end++
+                Ltr -> reverseLayout
+                Rtl -> !reverseLayout
             }
             Right -> when (layoutDirection) {
-                Ltr -> if (reverseLayout) start-- else end++
-                Rtl -> if (reverseLayout) end++ else start--
+                Ltr -> !reverseLayout
+                Rtl -> reverseLayout
             }
             else -> unsupportedDirection()
         }
+
+    private fun addNextInterval(
+        currentInterval: LazyLayoutBeyondBoundsInfo.Interval,
+        direction: BeyondBoundsLayout.LayoutDirection
+    ): LazyLayoutBeyondBoundsInfo.Interval {
+        var start = currentInterval.start
+        var end = currentInterval.end
+        if (direction.isForward()) {
+            end++
+        } else {
+            start--
+        }
         return beyondBoundsInfo.addInterval(start, end)
     }
 
-    private fun Interval.hasMoreContent(direction: BeyondBoundsLayout.LayoutDirection): Boolean {
-        fun hasMoreItemsBefore() = start > 0
-        fun hasMoreItemsAfter() = end < state.layoutInfo.totalItemsCount - 1
+    private fun LazyLayoutBeyondBoundsInfo.Interval.hasMoreContent(
+        direction: BeyondBoundsLayout.LayoutDirection
+    ): Boolean {
         if (direction.isOppositeToOrientation()) return false
-        return when (direction) {
-            Before -> hasMoreItemsBefore()
-            After -> hasMoreItemsAfter()
-            Above -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
-            Below -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
-            Left -> when (layoutDirection) {
-                Ltr -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
-                Rtl -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
-            }
-            Right -> when (layoutDirection) {
-                Ltr -> if (reverseLayout) hasMoreItemsBefore() else hasMoreItemsAfter()
-                Rtl -> if (reverseLayout) hasMoreItemsAfter() else hasMoreItemsBefore()
-            }
-            else -> unsupportedDirection()
-        }
+        return if (direction.isForward()) end < state.itemCount - 1 else start > 0
     }
 
     private fun BeyondBoundsLayout.LayoutDirection.isOppositeToOrientation(): Boolean {
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
index 34254cc..82f4199c 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyList.kt
@@ -27,6 +27,7 @@
 import androidx.compose.foundation.layout.calculateStartPadding
 import androidx.compose.foundation.lazy.layout.LazyLayout
 import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
+import androidx.compose.foundation.overscroll
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
@@ -34,6 +35,7 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntOffset
@@ -77,19 +79,14 @@
     /** The content of the list */
     content: TvLazyListScope.() -> Unit
 ) {
-    val itemProvider = rememberLazyListItemProvider(state, content)
+    val itemProviderLambda = rememberLazyListItemProviderLambda(state, content)
     val semanticState = rememberLazyListSemanticState(state, isVertical)
-    val beyondBoundsInfo = remember { LazyListBeyondBoundsInfo() }
     val scope = rememberCoroutineScope()
-    val placementAnimator = remember(state, isVertical) {
-        LazyListItemPlacementAnimator(scope, isVertical)
-    }
-    state.placementAnimator = placementAnimator
+    state.coroutineScope = scope
 
     val measurePolicy = rememberLazyListMeasurePolicy(
-        itemProvider,
+        itemProviderLambda,
         state,
-        beyondBoundsInfo,
         contentPadding,
         reverseLayout,
         isVertical,
@@ -97,12 +94,12 @@
         horizontalAlignment,
         verticalAlignment,
         horizontalArrangement,
-        verticalArrangement,
-        placementAnimator
+        verticalArrangement
     )
 
-    ScrollPositionUpdater(itemProvider, state)
+    ScrollPositionUpdater(itemProviderLambda, state)
 
+    val overscrollEffect = ScrollableDefaults.overscrollEffect()
     val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
 
     LazyLayout(
@@ -110,14 +107,20 @@
             .then(state.remeasurementModifier)
             .then(state.awaitLayoutModifier)
             .lazyLayoutSemantics(
-                itemProvider = itemProvider,
+                itemProviderLambda = itemProviderLambda,
                 state = semanticState,
                 orientation = orientation,
                 userScrollEnabled = userScrollEnabled,
                 reverseScrolling = reverseLayout
             )
             .clipScrollableContainer(orientation)
-            .lazyListBeyondBoundsModifier(state, beyondBoundsInfo, reverseLayout, orientation)
+            .lazyListBeyondBoundsModifier(
+                state,
+                beyondBoundsItemCount,
+                reverseLayout,
+                orientation
+            )
+            .overscroll(overscrollEffect)
             .scrollableWithPivot(
                 orientation = orientation,
                 reverseDirection = ScrollableDefaults.reverseDirection(
@@ -131,7 +134,7 @@
             ),
         prefetchState = state.prefetchState,
         measurePolicy = measurePolicy,
-        itemProvider = { itemProvider }
+        itemProvider = itemProviderLambda
     )
 }
 
@@ -140,24 +143,24 @@
 @ExperimentalFoundationApi
 @Composable
 private fun ScrollPositionUpdater(
-    itemProvider: LazyListItemProvider,
+    itemProviderLambda: () -> LazyListItemProvider,
     state: TvLazyListState
 ) {
+    val itemProvider = itemProviderLambda()
     if (itemProvider.itemCount > 0) {
         state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
     }
 }
 
+@OptIn(ExperimentalTvFoundationApi::class)
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @ExperimentalFoundationApi
 @Composable
 private fun rememberLazyListMeasurePolicy(
     /** Items provider of the list. */
-    itemProvider: LazyListItemProvider,
+    itemProviderLambda: () -> LazyListItemProvider,
     /** The state of the list. */
     state: TvLazyListState,
-    /** Keeps track of the number of items we measure and place that are beyond visible bounds. */
-    beyondBoundsInfo: LazyListBeyondBoundsInfo,
     /** The inner padding to be added for the whole content(nor for each individual item) */
     contentPadding: PaddingValues,
     /** reverse the direction of scrolling and layout */
@@ -173,22 +176,20 @@
     /** The horizontal arrangement for items. Required when isVertical is false */
     horizontalArrangement: Arrangement.Horizontal? = null,
     /** The vertical arrangement for items. Required when isVertical is true */
-    verticalArrangement: Arrangement.Vertical? = null,
-    /** Item placement animator. Should be notified with the measuring result */
-    placementAnimator: LazyListItemPlacementAnimator
+    verticalArrangement: Arrangement.Vertical? = null
 ) = remember<LazyLayoutMeasureScope.(Constraints) -> MeasureResult>(
     state,
-    beyondBoundsInfo,
     contentPadding,
     reverseLayout,
     isVertical,
     horizontalAlignment,
     verticalAlignment,
     horizontalArrangement,
-    verticalArrangement,
-    placementAnimator
+    verticalArrangement
 ) {
     { containerConstraints ->
+        // Tracks if the lookahead pass has occurred
+        val hasLookaheadPassOccurred = state.hasLookaheadPassOccurred || isLookingAhead
         checkScrollableContainerConstraints(
             containerConstraints,
             if (isVertical) Orientation.Vertical else Orientation.Horizontal
@@ -225,11 +226,10 @@
         val contentConstraints =
             containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding)
 
-        state.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
-
         // Update the state's cached Density
         state.density = this
 
+        val itemProvider = itemProviderLambda()
         // this will update the scope used by the item composables
         itemProvider.itemScope.setMaxSize(
             width = contentConstraints.maxWidth,
@@ -238,11 +238,11 @@
 
         val spaceBetweenItemsDp = if (isVertical) {
             requireNotNull(verticalArrangement) {
-                "encountered null verticalArrangement when isVertical == true"
+                "null verticalArrangement when isVertical == true"
             }.spacing
         } else {
             requireNotNull(horizontalArrangement) {
-                "encountered null horizontalArrangement when isVertical == false"
+                "null horizontalArrangement when isVertical == false"
             }.spacing
         }
         val spaceBetweenItems = spaceBetweenItemsDp.roundToPx()
@@ -267,43 +267,62 @@
             )
         }
 
-        val measuredItemProvider = LazyMeasuredItemProvider(
+        val measuredItemProvider = object : LazyListMeasuredItemProvider(
             contentConstraints,
             isVertical,
             itemProvider,
             this
-        ) { index, key, placeables ->
-            // we add spaceBetweenItems as an extra spacing for all items apart from the last one so
-            // the lazy list measuring logic will take it into account.
-            val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems
-            LazyMeasuredItem(
-                index = index.value,
-                placeables = placeables,
-                isVertical = isVertical,
-                horizontalAlignment = horizontalAlignment,
-                verticalAlignment = verticalAlignment,
-                layoutDirection = layoutDirection,
-                reverseLayout = reverseLayout,
-                beforeContentPadding = beforeContentPadding,
-                afterContentPadding = afterContentPadding,
-                spacing = spacing,
-                visualOffset = visualItemOffset,
-                key = key,
-                placementAnimator = placementAnimator
-            )
+        ) {
+            override fun createItem(
+                index: Int,
+                key: Any,
+                contentType: Any?,
+                placeables: List<Placeable>
+            ): LazyListMeasuredItem {
+                // we add spaceBetweenItems as an extra spacing for all items apart from the last one so
+                // the lazy list measuring logic will take it into account.
+                val spacing = if (index == itemsCount - 1) 0 else spaceBetweenItems
+                return LazyListMeasuredItem(
+                    index = index,
+                    placeables = placeables,
+                    isVertical = isVertical,
+                    horizontalAlignment = horizontalAlignment,
+                    verticalAlignment = verticalAlignment,
+                    layoutDirection = layoutDirection,
+                    reverseLayout = reverseLayout,
+                    beforeContentPadding = beforeContentPadding,
+                    afterContentPadding = afterContentPadding,
+                    spacing = spacing,
+                    visualOffset = visualItemOffset,
+                    key = key,
+                    contentType = contentType
+                )
+            }
         }
         state.premeasureConstraints = measuredItemProvider.childConstraints
 
-        val firstVisibleItemIndex: DataIndex
+        val firstVisibleItemIndex: Int
         val firstVisibleScrollOffset: Int
         Snapshot.withoutReadObservation {
-            firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex)
+            firstVisibleItemIndex = state.updateScrollPositionIfTheFirstItemWasMoved(
+                itemProvider, state.firstVisibleItemIndex
+            )
             firstVisibleScrollOffset = state.firstVisibleItemScrollOffset
         }
 
+        val pinnedItems = itemProvider.calculateLazyLayoutPinnedIndices(
+            pinnedItemList = state.pinnedItems,
+            beyondBoundsInfo = state.beyondBoundsInfo
+        )
+
+        val scrollToBeConsumed = if (isLookingAhead || !hasLookaheadPassOccurred) {
+            state.scrollToBeConsumed
+        } else {
+            state.scrollDeltaBetweenPasses
+        }
+
         measureLazyList(
             itemsCount = itemsCount,
-            itemProvider = itemProvider,
             measuredItemProvider = measuredItemProvider,
             mainAxisAvailableSize = mainAxisAvailableSize,
             beforeContentPadding = beforeContentPadding,
@@ -311,18 +330,20 @@
             spaceBetweenItems = spaceBetweenItems,
             firstVisibleItemIndex = firstVisibleItemIndex,
             firstVisibleItemScrollOffset = firstVisibleScrollOffset,
-            scrollToBeConsumed = state.scrollToBeConsumed,
+            scrollToBeConsumed = scrollToBeConsumed,
             constraints = contentConstraints,
             isVertical = isVertical,
             headerIndexes = itemProvider.headerIndexes,
             verticalArrangement = verticalArrangement,
             horizontalArrangement = horizontalArrangement,
             reverseLayout = reverseLayout,
-            beyondBoundsItemCount = beyondBoundsItemCount,
             density = this,
-            placementAnimator = placementAnimator,
-            beyondBoundsInfo = beyondBoundsInfo,
-            pinnedItems = state.pinnedItems,
+            placementAnimator = state.placementAnimator,
+            beyondBoundsItemCount = beyondBoundsItemCount,
+            pinnedItems = pinnedItems,
+            hasLookaheadPassOccurred = hasLookaheadPassOccurred,
+            isLookingAhead = isLookingAhead,
+            postLookaheadLayoutInfo = state.postLookaheadLayoutInfo,
             layout = { width, height, placement ->
                 layout(
                     containerConstraints.constrainWidth(width + totalHorizontalPadding),
@@ -331,6 +352,8 @@
                     placement
                 )
             }
-        ).also(state::applyMeasureResult)
+        ).also {
+            state.applyMeasureResult(it, isLookingAhead)
+        }
     }
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt
index 7544e90..b0b813f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListAnimateScrollScope.kt
@@ -50,8 +50,10 @@
     }
 
     override fun expectedDistanceTo(index: Int, targetScrollOffset: Int): Float {
-        val visibleItems = state.layoutInfo.visibleItemsInfo
-        val averageSize = visibleItems.fastSumBy { it.size } / visibleItems.size
+        val layoutInfo = state.layoutInfo
+        val visibleItems = layoutInfo.visibleItemsInfo
+        val averageSize =
+            visibleItems.fastSumBy { it.size } / visibleItems.size + layoutInfo.mainAxisItemSpacing
         val indexesDiff = index - firstVisibleItemIndex
         var coercedOffset = minOf(abs(targetScrollOffset), averageSize)
         if (targetScrollOffset < 0) coercedOffset *= -1
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt
index d20d6f5..08528c1 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListHeaders.kt
@@ -28,13 +28,13 @@
  * @param beforeContentPadding the padding before the first item in the list
  */
 internal fun findOrComposeLazyListHeader(
-    composedVisibleItems: MutableList<LazyListPositionedItem>,
-    itemProvider: LazyMeasuredItemProvider,
+    composedVisibleItems: MutableList<LazyListMeasuredItem>,
+    itemProvider: LazyListMeasuredItemProvider,
     headerIndexes: List<Int>,
     beforeContentPadding: Int,
     layoutWidth: Int,
     layoutHeight: Int,
-): LazyListPositionedItem? {
+): LazyListMeasuredItem? {
     var currentHeaderOffset: Int = Int.MIN_VALUE
     var nextHeaderOffset: Int = Int.MIN_VALUE
 
@@ -70,7 +70,7 @@
         return null
     }
 
-    val measuredHeaderItem = itemProvider.getAndMeasure(DataIndex(currentHeaderListPosition))
+    val measuredHeaderItem = itemProvider.getAndMeasure(currentHeaderListPosition)
 
     var headerOffset = if (currentHeaderOffset != Int.MIN_VALUE) {
         maxOf(-beforeContentPadding, currentHeaderOffset)
@@ -83,11 +83,11 @@
         headerOffset = minOf(headerOffset, nextHeaderOffset - measuredHeaderItem.size)
     }
 
-    return measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight).also {
-        if (indexInComposedVisibleItems != -1) {
-            composedVisibleItems[indexInComposedVisibleItems] = it
-        } else {
-            composedVisibleItems.add(0, it)
-        }
+    measuredHeaderItem.position(headerOffset, layoutWidth, layoutHeight)
+    if (indexInComposedVisibleItems != -1) {
+        composedVisibleItems[indexInComposedVisibleItems] = measuredHeaderItem
+    } else {
+        composedVisibleItems.add(0, measuredHeaderItem)
     }
+    return measuredHeaderItem
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt
index 431d51a..a141f21 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemPlacementAnimator.kt
@@ -16,24 +16,11 @@
 
 package androidx.tv.foundation.lazy.list
 
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.SpringSpec
-import androidx.compose.animation.core.VectorConverter
-import androidx.compose.animation.core.VisibilityThreshold
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.util.fastAny
 import androidx.compose.ui.util.fastForEach
-import androidx.compose.ui.util.fastForEachIndexed
+import androidx.tv.foundation.lazy.grid.LazyLayoutAnimateItemModifierNode
 import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
 
 /**
  * Handles the item placement animations when it is set via [TvLazyListItemScope.animateItemPlacement].
@@ -41,26 +28,22 @@
  * This class is responsible for detecting when item position changed, figuring our start/end
  * offsets and starting the animations.
  */
-@OptIn(ExperimentalFoundationApi::class)
-internal class LazyListItemPlacementAnimator(
-    private val scope: CoroutineScope,
-    private val isVertical: Boolean
-) {
-    // state containing an animation and all relevant info for each item.
-    private val keyToItemInfoMap = mutableMapOf<Any, ItemInfo>()
+internal class LazyListItemPlacementAnimator {
+    // contains the keys of the active items with animation node.
+    private val activeKeys = mutableSetOf<Any>()
 
     // snapshot of the key to index map used for the last measuring.
-    private var keyToIndexMap: LazyLayoutKeyIndexMap = LazyLayoutKeyIndexMap.Empty
+    private var keyIndexMap: LazyLayoutKeyIndexMap = LazyLayoutKeyIndexMap.Empty
 
     // keeps the index of the first visible item index.
     private var firstVisibleIndex = 0
 
     // stored to not allocate it every pass.
     private val movingAwayKeys = LinkedHashSet<Any>()
-    private val movingInFromStartBound = mutableListOf<LazyListPositionedItem>()
-    private val movingInFromEndBound = mutableListOf<LazyListPositionedItem>()
-    private val movingAwayToStartBound = mutableListOf<LazyMeasuredItem>()
-    private val movingAwayToEndBound = mutableListOf<LazyMeasuredItem>()
+    private val movingInFromStartBound = mutableListOf<LazyListMeasuredItem>()
+    private val movingInFromEndBound = mutableListOf<LazyListMeasuredItem>()
+    private val movingAwayToStartBound = mutableListOf<LazyListMeasuredItem>()
+    private val movingAwayToEndBound = mutableListOf<LazyListMeasuredItem>()
 
     /**
      * Should be called after the measuring so we can detect position changes and start animations.
@@ -71,10 +54,13 @@
         consumedScroll: Int,
         layoutWidth: Int,
         layoutHeight: Int,
-        positionedItems: MutableList<LazyListPositionedItem>,
-        itemProvider: LazyMeasuredItemProvider
+        positionedItems: MutableList<LazyListMeasuredItem>,
+        itemProvider: LazyListMeasuredItemProvider,
+        isVertical: Boolean,
+        isLookingAhead: Boolean,
+        hasLookaheadOccurred: Boolean
     ) {
-        if (!positionedItems.fastAny { it.hasAnimations } && keyToItemInfoMap.isEmpty()) {
+        if (!positionedItems.fastAny { it.hasAnimations } && activeKeys.isEmpty()) {
             // no animations specified - no work needed
             reset()
             return
@@ -82,25 +68,32 @@
 
         val previousFirstVisibleIndex = firstVisibleIndex
         firstVisibleIndex = positionedItems.firstOrNull()?.index ?: 0
-        val previousKeyToIndexMap = keyToIndexMap
-        keyToIndexMap = itemProvider.keyToIndexMap
+
+        val previousKeyToIndexMap = keyIndexMap
+        keyIndexMap = itemProvider.keyIndexMap
 
         val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
 
         // the consumed scroll is considered as a delta we don't need to animate
-        val notAnimatableDelta = consumedScroll.toOffset()
+        val scrollOffset = if (isVertical) {
+            IntOffset(0, consumedScroll)
+        } else {
+            IntOffset(consumedScroll, 0)
+        }
 
+        // Only setup animations when we have access to target value in the current pass, which
+        // means lookahead pass, or regular pass when not in a lookahead scope.
+        val shouldSetupAnimation = isLookingAhead || !hasLookaheadOccurred
         // first add all items we had in the previous run
-        movingAwayKeys.addAll(keyToItemInfoMap.keys)
+        movingAwayKeys.addAll(activeKeys)
         // iterate through the items which are visible (without animated offsets)
         positionedItems.fastForEach { item ->
             // remove items we have in the current one as they are still visible.
             movingAwayKeys.remove(item.key)
             if (item.hasAnimations) {
-                val itemInfo = keyToItemInfoMap[item.key]
-                // there is no state associated with this item yet
-                if (itemInfo == null) {
-                    val previousIndex = previousKeyToIndexMap[item.key]
+                if (!activeKeys.contains(item.key)) {
+                    activeKeys += item.key
+                    val previousIndex = previousKeyToIndexMap.getIndex(item.key)
                     if (previousIndex != -1 && item.index != previousIndex) {
                         if (previousIndex < previousFirstVisibleIndex) {
                             // the larger index will be in the start of the list
@@ -109,85 +102,105 @@
                             movingInFromEndBound.add(item)
                         }
                     } else {
-                        keyToItemInfoMap[item.key] = createItemInfo(item)
+                        initializeNode(
+                            item,
+                            item.getOffset(0).let { if (item.isVertical) it.y else it.x }
+                        )
                     }
                 } else {
-                    // this item was visible and is still visible.
-                    itemInfo.notAnimatableDelta += notAnimatableDelta // apply new scroll delta
-                    startAnimationsIfNeeded(item, itemInfo)
+                    if (shouldSetupAnimation) {
+                        item.forEachNode { _, node ->
+                            if (node.rawOffset != LazyLayoutAnimateItemModifierNode.NotInitialized
+                            ) {
+                                node.rawOffset += scrollOffset
+                            }
+                        }
+                        startAnimationsIfNeeded(item)
+                    }
                 }
             } else {
                 // no animation, clean up if needed
-                keyToItemInfoMap.remove(item.key)
+                activeKeys.remove(item.key)
             }
         }
 
-        var currentMainAxisOffset = 0
-        movingInFromStartBound.sortByDescending { previousKeyToIndexMap[it.key] }
-        movingInFromStartBound.fastForEach { item ->
-            val mainAxisOffset = 0 - currentMainAxisOffset - item.size
-            currentMainAxisOffset += item.size
-            val itemInfo = createItemInfo(item, mainAxisOffset)
-            keyToItemInfoMap[item.key] = itemInfo
-            startAnimationsIfNeeded(item, itemInfo)
-        }
-        currentMainAxisOffset = 0
-        movingInFromEndBound.sortBy { previousKeyToIndexMap[it.key] }
-        movingInFromEndBound.fastForEach { item ->
-            val mainAxisOffset = mainAxisLayoutSize + currentMainAxisOffset
-            currentMainAxisOffset += item.size
-            val itemInfo = createItemInfo(item, mainAxisOffset)
-            keyToItemInfoMap[item.key] = itemInfo
-            startAnimationsIfNeeded(item, itemInfo)
+        var accumulatedOffset = 0
+        if (shouldSetupAnimation) {
+            movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
+            movingInFromStartBound.fastForEach { item ->
+                accumulatedOffset += item.size
+                val mainAxisOffset = 0 - accumulatedOffset
+                initializeNode(item, mainAxisOffset)
+                startAnimationsIfNeeded(item)
+            }
+            accumulatedOffset = 0
+            movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
+            movingInFromEndBound.fastForEach { item ->
+                val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
+                accumulatedOffset += item.size
+                initializeNode(item, mainAxisOffset)
+                startAnimationsIfNeeded(item)
+            }
         }
 
         movingAwayKeys.forEach { key ->
             // found an item which was in our map previously but is not a part of the
             // positionedItems now
-            val itemInfo = keyToItemInfoMap.getValue(key)
-            val newIndex = keyToIndexMap[key]
+            val newIndex = keyIndexMap.getIndex(key)
 
-            // whether the animation associated with the item has been finished or not yet started
-            val inProgress = itemInfo.placeables.fastAny { it.inProgress }
-            if (itemInfo.placeables.isEmpty() ||
-                newIndex == -1 ||
-                (!inProgress && newIndex == previousKeyToIndexMap[key]) ||
-                (!inProgress && !itemInfo.isWithinBounds(mainAxisLayoutSize))
-            ) {
-                keyToItemInfoMap.remove(key)
+            if (newIndex == -1) {
+                activeKeys.remove(key)
             } else {
-                val item = itemProvider.getAndMeasure(DataIndex(newIndex))
-                if (newIndex < firstVisibleIndex) {
-                    movingAwayToStartBound.add(item)
+                val item = itemProvider.getAndMeasure(newIndex)
+                // check if we have any active placement animation on the item
+                var inProgress = false
+                repeat(item.placeablesCount) {
+                    if (item.getParentData(it).node?.isAnimationInProgress == true) {
+                        inProgress = true
+                        return@repeat
+                    }
+                }
+                if ((!inProgress && newIndex == previousKeyToIndexMap.getIndex(key))) {
+                    activeKeys.remove(key)
                 } else {
-                    movingAwayToEndBound.add(item)
+                    if (newIndex < firstVisibleIndex) {
+                        movingAwayToStartBound.add(item)
+                    } else {
+                        movingAwayToEndBound.add(item)
+                    }
                 }
             }
         }
 
-        currentMainAxisOffset = 0
-        movingAwayToStartBound.sortByDescending { keyToIndexMap[it.key] }
+        accumulatedOffset = 0
+        movingAwayToStartBound.sortByDescending { keyIndexMap.getIndex(it.key) }
         movingAwayToStartBound.fastForEach { item ->
-            val mainAxisOffset = 0 - currentMainAxisOffset - item.size
-            currentMainAxisOffset += item.size
+            accumulatedOffset += item.size
+            val mainAxisOffset = 0 - accumulatedOffset
 
-            val itemInfo = keyToItemInfoMap.getValue(item.key)
-            val positionedItem = item.position(mainAxisOffset, layoutWidth, layoutHeight)
-            positionedItems.add(positionedItem)
-            startAnimationsIfNeeded(positionedItem, itemInfo)
+            item.position(mainAxisOffset, layoutWidth, layoutHeight)
+            if (shouldSetupAnimation) {
+                startAnimationsIfNeeded(item)
+            }
         }
-        currentMainAxisOffset = 0
-        movingAwayToEndBound.sortBy { keyToIndexMap[it.key] }
+
+        accumulatedOffset = 0
+        movingAwayToEndBound.sortBy { keyIndexMap.getIndex(it.key) }
         movingAwayToEndBound.fastForEach { item ->
-            val mainAxisOffset = mainAxisLayoutSize + currentMainAxisOffset
-            currentMainAxisOffset += item.size
+            val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
+            accumulatedOffset += item.size
 
-            val itemInfo = keyToItemInfoMap.getValue(item.key)
-            val positionedItem = item.position(mainAxisOffset, layoutWidth, layoutHeight)
-            positionedItems.add(positionedItem)
-            startAnimationsIfNeeded(positionedItem, itemInfo)
+            item.position(mainAxisOffset, layoutWidth, layoutHeight)
+            if (shouldSetupAnimation) {
+                startAnimationsIfNeeded(item)
+            }
         }
 
+        // This adds the new items to the list of positioned items while keeping the index of
+        // the positioned items sorted in ascending order.
+        positionedItems.addAll(0, movingAwayToStartBound.apply { reverse() })
+        positionedItems.addAll(movingAwayToEndBound)
+
         movingInFromStartBound.clear()
         movingInFromEndBound.clear()
         movingAwayToStartBound.clear()
@@ -196,157 +209,61 @@
     }
 
     /**
-     * Returns the current animated item placement offset. By calling it only during the layout
-     * phase we can skip doing remeasure on every animation frame.
-     */
-    fun getAnimatedOffset(
-        key: Any,
-        placeableIndex: Int,
-        minOffset: Int,
-        maxOffset: Int,
-        rawOffset: IntOffset
-    ): IntOffset {
-        val itemInfo = keyToItemInfoMap[key] ?: return rawOffset
-        val item = itemInfo.placeables[placeableIndex]
-        val currentValue = item.animatedOffset.value + itemInfo.notAnimatableDelta
-        val currentTarget = item.targetOffset + itemInfo.notAnimatableDelta
-
-        // cancel the animation if it is fully out of the bounds.
-        if (item.inProgress &&
-            ((currentTarget.mainAxis <= minOffset && currentValue.mainAxis <= minOffset) ||
-                (currentTarget.mainAxis >= maxOffset && currentValue.mainAxis >= maxOffset))
-        ) {
-            scope.launch {
-                item.animatedOffset.snapTo(item.targetOffset)
-                item.inProgress = false
-            }
-        }
-
-        return currentValue
-    }
-
-    /**
      * Should be called when the animations are not needed for the next positions change,
      * for example when we snap to a new position.
      */
     fun reset() {
-        keyToItemInfoMap.clear()
-        keyToIndexMap = LazyLayoutKeyIndexMap.Empty
+        activeKeys.clear()
+        keyIndexMap = LazyLayoutKeyIndexMap.Empty
         firstVisibleIndex = -1
     }
 
-    private fun createItemInfo(
-        item: LazyListPositionedItem,
-        mainAxisOffset: Int = item.getOffset(0).mainAxis
-    ): ItemInfo {
-        val newItemInfo = ItemInfo()
+    private fun initializeNode(
+        item: LazyListMeasuredItem,
+        mainAxisOffset: Int
+    ) {
         val firstPlaceableOffset = item.getOffset(0)
 
-        val targetFirstPlaceableOffset = if (isVertical) {
+        val targetFirstPlaceableOffset = if (item.isVertical) {
             firstPlaceableOffset.copy(y = mainAxisOffset)
         } else {
             firstPlaceableOffset.copy(x = mainAxisOffset)
         }
 
-        // populate placeable info list
-        repeat(item.placeablesCount) { placeableIndex ->
+        // initialize offsets
+        item.forEachNode { placeableIndex, node ->
             val diffToFirstPlaceableOffset =
                 item.getOffset(placeableIndex) - firstPlaceableOffset
-            newItemInfo.placeables.add(
-                PlaceableInfo(
-                    targetFirstPlaceableOffset + diffToFirstPlaceableOffset,
-                    item.getMainAxisSize(placeableIndex)
-                )
-            )
+            node.rawOffset = targetFirstPlaceableOffset + diffToFirstPlaceableOffset
         }
-        return newItemInfo
     }
 
-    private fun startAnimationsIfNeeded(item: LazyListPositionedItem, itemInfo: ItemInfo) {
-        // first we make sure our item info is up to date (has the item placeables count)
-        while (itemInfo.placeables.size > item.placeablesCount) {
-            itemInfo.placeables.removeLast()
-        }
-        while (itemInfo.placeables.size < item.placeablesCount) {
-            val newPlaceableInfoIndex = itemInfo.placeables.size
-            val rawOffset = item.getOffset(newPlaceableInfoIndex)
-            itemInfo.placeables.add(
-                PlaceableInfo(
-                    rawOffset - itemInfo.notAnimatableDelta,
-                    item.getMainAxisSize(newPlaceableInfoIndex)
-                )
-            )
-        }
-
-        itemInfo.placeables.fastForEachIndexed { index, placeableInfo ->
-            val currentTarget = placeableInfo.targetOffset + itemInfo.notAnimatableDelta
-            val currentOffset = item.getOffset(index)
-            placeableInfo.mainAxisSize = item.getMainAxisSize(index)
-            val animationSpec = item.getAnimationSpec(index)
-            if (currentTarget != currentOffset) {
-                placeableInfo.targetOffset = currentOffset - itemInfo.notAnimatableDelta
-                if (animationSpec != null) {
-                    placeableInfo.inProgress = true
-                    scope.launch {
-                        val finalSpec = if (placeableInfo.animatedOffset.isRunning) {
-                            // when interrupted, use the default spring, unless the spec is a spring.
-                            if (animationSpec is SpringSpec<IntOffset>) animationSpec else
-                                InterruptionSpec
-                        } else {
-                            animationSpec
-                        }
-
-                        try {
-                            placeableInfo.animatedOffset.animateTo(
-                                placeableInfo.targetOffset,
-                                finalSpec
-                            )
-                            placeableInfo.inProgress = false
-                        } catch (_: CancellationException) {
-                            // we don't reset inProgress in case of cancellation as it means
-                            // there is a new animation started which would reset it later
-                        }
-                    }
-                }
+    private fun startAnimationsIfNeeded(item: LazyListMeasuredItem) {
+        item.forEachNode { placeableIndex, node ->
+            val newTarget = item.getOffset(placeableIndex)
+            val currentTarget = node.rawOffset
+            if (currentTarget != LazyLayoutAnimateItemModifierNode.NotInitialized &&
+                currentTarget != newTarget
+            ) {
+                node.animatePlacementDelta(newTarget - currentTarget)
             }
+            node.rawOffset = newTarget
         }
     }
 
-    /**
-     * Whether at least one placeable is within the viewport bounds.
-     */
-    private fun ItemInfo.isWithinBounds(mainAxisLayoutSize: Int): Boolean {
-        return placeables.fastAny {
-            val currentTarget = it.targetOffset + notAnimatableDelta
-            currentTarget.mainAxis + it.mainAxisSize > 0 &&
-                currentTarget.mainAxis < mainAxisLayoutSize
+    private val Any?.node get() = this as? LazyLayoutAnimateItemModifierNode
+
+    private val LazyListMeasuredItem.hasAnimations: Boolean
+        get() {
+            forEachNode { _, _ -> return true }
+            return false
+        }
+
+    private inline fun LazyListMeasuredItem.forEachNode(
+        block: (placeableIndex: Int, node: LazyLayoutAnimateItemModifierNode) -> Unit
+    ) {
+        repeat(placeablesCount) { index ->
+            getParentData(index).node?.let { block(index, it) }
         }
     }
-
-    private fun Int.toOffset() =
-        IntOffset(if (isVertical) 0 else this, if (!isVertical) 0 else this)
-
-    private val IntOffset.mainAxis get() = if (isVertical) y else x
 }
-
-private class ItemInfo {
-    var notAnimatableDelta: IntOffset = IntOffset.Zero
-    val placeables = mutableListOf<PlaceableInfo>()
-}
-
-private class PlaceableInfo(
-    initialOffset: IntOffset,
-    var mainAxisSize: Int
-) {
-    val animatedOffset = Animatable(initialOffset, IntOffset.VectorConverter)
-    var targetOffset: IntOffset = initialOffset
-    var inProgress by mutableStateOf(false)
-}
-
-/**
- * We switch to this spec when a duration based animation is being interrupted.
- */
-private val InterruptionSpec = spring(
-    stiffness = Spring.StiffnessMediumLow,
-    visibilityThreshold = IntOffset.VisibilityThreshold
-)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
index 613ce8f..92c4589 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListItemProvider.kt
@@ -21,17 +21,16 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnableItem
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
 import androidx.compose.runtime.referentialEqualityPolicy
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
-import androidx.tv.foundation.lazy.layout.NearestRangeKeyIndexMapState
+import androidx.tv.foundation.lazy.layout.NearestRangeKeyIndexMap
 
 @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @ExperimentalFoundationApi
 internal interface LazyListItemProvider : LazyLayoutItemProvider {
-    val keyToIndexMap: LazyLayoutKeyIndexMap
+    val keyIndexMap: LazyLayoutKeyIndexMap
     /** The list of indexes of the sticky header items */
     val headerIndexes: List<Int>
     /** The scope used by the item content lambdas */
@@ -40,64 +39,68 @@
 
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
-internal fun rememberLazyListItemProvider(
+internal fun rememberLazyListItemProviderLambda(
     state: TvLazyListState,
     content: TvLazyListScope.() -> Unit
-): LazyListItemProvider {
+): () -> LazyListItemProvider {
     val latestContent = rememberUpdatedState(content)
-    return remember(state, latestContent) {
-        LazyListItemProviderImpl(
-            state = state,
-            latestContent = { latestContent.value },
-            itemScope = TvLazyListItemScopeImpl()
-        )
+    return remember(state) {
+        val scope = TvLazyListItemScopeImpl()
+        val intervalContentState = derivedStateOf(referentialEqualityPolicy()) {
+            TvLazyListIntervalContent(latestContent.value)
+        }
+        val itemProviderState = derivedStateOf(referentialEqualityPolicy()) {
+            val intervalContent = intervalContentState.value
+            val map = NearestRangeKeyIndexMap(state.nearestRange, intervalContent)
+            LazyListItemProviderImpl(
+                state = state,
+                intervalContent = intervalContent,
+                itemScope = scope,
+                keyIndexMap = map
+            )
+        }
+        itemProviderState::value
     }
 }
 
 @ExperimentalFoundationApi
 private class LazyListItemProviderImpl constructor(
     private val state: TvLazyListState,
-    private val latestContent: () -> (TvLazyListScope.() -> Unit),
-    override val itemScope: TvLazyListItemScopeImpl
+    private val intervalContent: TvLazyListIntervalContent,
+    override val itemScope: TvLazyListItemScopeImpl,
+    override val keyIndexMap: LazyLayoutKeyIndexMap,
 ) : LazyListItemProvider {
-    private val listContent by derivedStateOf(referentialEqualityPolicy()) {
-        TvLazyListIntervalContent(latestContent())
-    }
 
-    override val itemCount: Int get() = listContent.itemCount
+    override val itemCount: Int get() = intervalContent.itemCount
 
     @Composable
     override fun Item(index: Int, key: Any) {
         LazyLayoutPinnableItem(key, index, state.pinnedItems) {
-            listContent.withInterval(index) { localIndex, content ->
+            intervalContent.withInterval(index) { localIndex, content ->
                 content.item(itemScope, localIndex)
             }
         }
     }
 
-    override fun getKey(index: Int): Any = listContent.getKey(index)
+    override fun getKey(index: Int): Any =
+        keyIndexMap.getKey(index) ?: intervalContent.getKey(index)
 
-    override fun getContentType(index: Int): Any? = listContent.getContentType(index)
+    override fun getContentType(index: Int): Any? = intervalContent.getContentType(index)
 
-    override val headerIndexes: List<Int> get() = listContent.headerIndexes
+    override val headerIndexes: List<Int> get() = intervalContent.headerIndexes
 
-    override val keyToIndexMap by NearestRangeKeyIndexMapState(
-        firstVisibleItemIndex = { state.firstVisibleItemIndex },
-        slidingWindowSize = { NearestItemsSlidingWindowSize },
-        extraItemCount = { NearestItemsExtraItemCount },
-        content = { listContent }
-    )
+    override fun getIndex(key: Any): Int = keyIndexMap.getIndex(key)
 
-    override fun getIndex(key: Any): Int = keyToIndexMap[key]
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is LazyListItemProviderImpl) return false
+
+        // the identity of this class is represented by intervalContent object.
+        // having equals() allows us to skip items recomposition when intervalContent didn't change
+        return intervalContent == other.intervalContent
+    }
+
+    override fun hashCode(): Int {
+        return intervalContent.hashCode()
+    }
 }
-
-/**
- * We use the idea of sliding window as an optimization, so user can scroll up to this number of
- * items until we have to regenerate the key to index map.
- */
-private const val NearestItemsSlidingWindowSize = 30
-
-/**
- * The minimum amount of items near the current first visible item we want to have mapping for.
- */
-private const val NearestItemsExtraItemCount = 100
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
index 07c4b07..e5396f5 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasure.kt
@@ -19,7 +19,6 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
@@ -27,11 +26,12 @@
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastFirstOrNull
 import androidx.compose.ui.util.fastForEach
 import kotlin.contracts.ExperimentalContracts
 import kotlin.contracts.contract
 import kotlin.math.abs
-import kotlin.math.min
 import kotlin.math.roundToInt
 import kotlin.math.sign
 
@@ -43,13 +43,12 @@
 @OptIn(ExperimentalFoundationApi::class)
 internal fun measureLazyList(
     itemsCount: Int,
-    itemProvider: LazyListItemProvider,
-    measuredItemProvider: LazyMeasuredItemProvider,
+    measuredItemProvider: LazyListMeasuredItemProvider,
     mainAxisAvailableSize: Int,
     beforeContentPadding: Int,
     afterContentPadding: Int,
     spaceBetweenItems: Int,
-    firstVisibleItemIndex: DataIndex,
+    firstVisibleItemIndex: Int,
     firstVisibleItemScrollOffset: Int,
     scrollToBeConsumed: Float,
     constraints: Constraints,
@@ -60,13 +59,16 @@
     reverseLayout: Boolean,
     density: Density,
     placementAnimator: LazyListItemPlacementAnimator,
-    beyondBoundsInfo: LazyListBeyondBoundsInfo,
     beyondBoundsItemCount: Int,
-    pinnedItems: LazyLayoutPinnedItemList,
+    pinnedItems: List<Int>,
+    hasLookaheadPassOccurred: Boolean,
+    isLookingAhead: Boolean,
+    postLookaheadLayoutInfo: TvLazyListLayoutInfo?,
+    @Suppress("PrimitiveInLambda")
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): LazyListMeasureResult {
-    require(beforeContentPadding >= 0) { "negative beforeContentPadding" }
-    require(afterContentPadding >= 0) { "negative afterContentPadding" }
+    require(beforeContentPadding >= 0) { "invalid beforeContentPadding" }
+    require(afterContentPadding >= 0) { "invalid afterContentPadding" }
     if (itemsCount <= 0) {
         // empty data set. reset the current scroll and report zero size
         return LazyListMeasureResult(
@@ -75,6 +77,7 @@
             canScrollForward = false,
             consumedScroll = 0f,
             measureResult = layout(constraints.minWidth, constraints.minHeight) {},
+            scrollBackAmount = 0f,
             visibleItemsInfo = emptyList(),
             viewportStartOffset = -beforeContentPadding,
             viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
@@ -87,10 +90,10 @@
     } else {
         var currentFirstItemIndex = firstVisibleItemIndex
         var currentFirstItemScrollOffset = firstVisibleItemScrollOffset
-        if (currentFirstItemIndex.value >= itemsCount) {
+        if (currentFirstItemIndex >= itemsCount) {
             // the data set has been updated and now we have less items that we were
             // scrolled to before
-            currentFirstItemIndex = DataIndex(itemsCount - 1)
+            currentFirstItemIndex = itemsCount - 1
             currentFirstItemScrollOffset = 0
         }
 
@@ -102,13 +105,13 @@
         currentFirstItemScrollOffset -= scrollDelta
 
         // if the current scroll offset is less than minimally possible
-        if (currentFirstItemIndex == DataIndex(0) && currentFirstItemScrollOffset < 0) {
+        if (currentFirstItemIndex == 0 && currentFirstItemScrollOffset < 0) {
             scrollDelta += currentFirstItemScrollOffset
             currentFirstItemScrollOffset = 0
         }
 
         // this will contain all the MeasuredItems representing the visible items
-        val visibleItems = mutableListOf<LazyMeasuredItem>()
+        val visibleItems = ArrayDeque<LazyListMeasuredItem>()
 
         // define min and max offsets
         val minOffset = -beforeContentPadding + if (spaceBetweenItems < 0) spaceBetweenItems else 0
@@ -125,8 +128,8 @@
         // we had scrolled backward or we compose items in the start padding area, which means
         // items before current firstItemScrollOffset should be visible. compose them and update
         // firstItemScrollOffset
-        while (currentFirstItemScrollOffset < 0 && currentFirstItemIndex > DataIndex(0)) {
-            val previous = DataIndex(currentFirstItemIndex.value - 1)
+        while (currentFirstItemScrollOffset < 0 && currentFirstItemIndex > 0) {
+            val previous = currentFirstItemIndex - 1
             val measuredItem = measuredItemProvider.getAndMeasure(previous)
             visibleItems.add(0, measuredItem)
             maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
@@ -157,7 +160,7 @@
         // then composing visible items forward until we fill the whole viewport.
         // we want to have at least one item in visibleItems even if in fact all the items are
         // offscreen, this can happen if the content padding is larger than the available size.
-        while (index.value < itemsCount &&
+        while (index < itemsCount &&
             (currentMainAxisOffset < maxMainAxis ||
                 currentMainAxisOffset <= 0 || // filling beforeContentPadding area
                 visibleItems.isEmpty())
@@ -165,7 +168,7 @@
             val measuredItem = measuredItemProvider.getAndMeasure(index)
             currentMainAxisOffset += measuredItem.sizeWithSpacings
 
-            if (currentMainAxisOffset <= minOffset && index.value != itemsCount - 1) {
+            if (currentMainAxisOffset <= minOffset && index != itemsCount - 1) {
                 // this item is offscreen and will not be placed. advance firstVisibleItemIndex
                 currentFirstItemIndex = index + 1
                 currentFirstItemScrollOffset -= measuredItem.sizeWithSpacings
@@ -177,6 +180,7 @@
             index++
         }
 
+        val preScrollBackScrollDelta = scrollDelta
         // we didn't fill the whole viewport with items starting from firstVisibleItemIndex.
         // lets try to scroll back if we have enough items before firstVisibleItemIndex.
         if (currentMainAxisOffset < maxOffset) {
@@ -184,9 +188,9 @@
             currentFirstItemScrollOffset -= toScrollBack
             currentMainAxisOffset += toScrollBack
             while (currentFirstItemScrollOffset < beforeContentPadding &&
-                currentFirstItemIndex > DataIndex(0)
+                currentFirstItemIndex > 0
             ) {
-                val previousIndex = DataIndex(currentFirstItemIndex.value - 1)
+                val previousIndex = currentFirstItemIndex - 1
                 val measuredItem = measuredItemProvider.getAndMeasure(previousIndex)
                 visibleItems.add(0, measuredItem)
                 maxCrossAxis = maxOf(maxCrossAxis, measuredItem.crossAxisSize)
@@ -213,8 +217,18 @@
             scrollToBeConsumed
         }
 
+        val unconsumedScroll = scrollToBeConsumed - consumedScroll
+        // When scrolling to the bottom via gesture, there could be scrollback due to
+        // not being able to consume the whole scroll. In that case, the amount of
+        // scrollBack is the inverse of unconsumed scroll.
+        val scrollBackAmount: Float =
+            if (isLookingAhead && scrollDelta > preScrollBackScrollDelta && unconsumedScroll <= 0) {
+                scrollDelta - preScrollBackScrollDelta + unconsumedScroll
+            } else
+                0f
+
         // the initial offset for items from visibleItems list
-        require(currentFirstItemScrollOffset >= 0) { "negative scroll offset" }
+        require(currentFirstItemScrollOffset >= 0) { "negative currentFirstItemScrollOffset" }
         val visibleItemsScrollOffset = -currentFirstItemScrollOffset
         var firstItem = visibleItems.first()
 
@@ -236,11 +250,8 @@
 
         // Compose extra items before
         val extraItemsBefore = createItemsBeforeList(
-            beyondBoundsInfo = beyondBoundsInfo,
             currentFirstItemIndex = currentFirstItemIndex,
             measuredItemProvider = measuredItemProvider,
-            itemProvider = itemProvider,
-            itemsCount = itemsCount,
             beyondBoundsItemCount = beyondBoundsItemCount,
             pinnedItems = pinnedItems
         )
@@ -252,13 +263,14 @@
 
         // Compose items after last item
         val extraItemsAfter = createItemsAfterList(
-            beyondBoundsInfo = beyondBoundsInfo,
             visibleItems = visibleItems,
             measuredItemProvider = measuredItemProvider,
-            itemProvider = itemProvider,
             itemsCount = itemsCount,
             beyondBoundsItemCount = beyondBoundsItemCount,
-            pinnedItems = pinnedItems
+            pinnedItems = pinnedItems,
+            consumedScroll = consumedScroll,
+            isLookingAhead = isLookingAhead,
+            lastPostLookaheadLayoutInfo = postLookaheadLayoutInfo
         )
 
         // Update maxCrossAxis with extra items
@@ -296,7 +308,10 @@
             layoutWidth = layoutWidth,
             layoutHeight = layoutHeight,
             positionedItems = positionedItems,
-            itemProvider = measuredItemProvider
+            itemProvider = measuredItemProvider,
+            isVertical = isVertical,
+            isLookingAhead = isLookingAhead,
+            hasLookaheadOccurred = hasLookaheadPassOccurred
         )
 
         val headerItem = if (headerIndexes.isNotEmpty()) {
@@ -315,23 +330,24 @@
         return LazyListMeasureResult(
             firstVisibleItem = firstItem,
             firstVisibleItemScrollOffset = currentFirstItemScrollOffset,
-            canScrollForward = index.value < itemsCount || currentMainAxisOffset > maxOffset,
+            canScrollForward = index < itemsCount || currentMainAxisOffset > maxOffset,
             consumedScroll = consumedScroll,
             measureResult = layout(layoutWidth, layoutHeight) {
                 positionedItems.fastForEach {
                     if (it !== headerItem) {
-                        it.place(this)
+                        it.place(this, isLookingAhead)
                     }
                 }
                 // the header item should be placed (drawn) after all other items
-                headerItem?.place(this)
+                headerItem?.place(this, isLookingAhead)
             },
-            viewportStartOffset = -beforeContentPadding,
-            viewportEndOffset = maxOffset + afterContentPadding,
+            scrollBackAmount = scrollBackAmount,
             visibleItemsInfo = if (noExtraItems) positionedItems else positionedItems.fastFilter {
                 (it.index >= visibleItems.first().index && it.index <= visibleItems.last().index) ||
                     it === headerItem
             },
+            viewportStartOffset = -beforeContentPadding,
+            viewportEndOffset = maxOffset + afterContentPadding,
             totalItemsCount = itemsCount,
             reverseLayout = reverseLayout,
             orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
@@ -341,88 +357,112 @@
     }
 }
 
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-@OptIn(ExperimentalFoundationApi::class)
 private fun createItemsAfterList(
-    beyondBoundsInfo: LazyListBeyondBoundsInfo,
-    visibleItems: MutableList<LazyMeasuredItem>,
-    measuredItemProvider: LazyMeasuredItemProvider,
-    itemProvider: LazyListItemProvider,
+    visibleItems: MutableList<LazyListMeasuredItem>,
+    measuredItemProvider: LazyListMeasuredItemProvider,
     itemsCount: Int,
     beyondBoundsItemCount: Int,
-    pinnedItems: LazyLayoutPinnedItemList
-): List<LazyMeasuredItem> {
-    fun LazyListBeyondBoundsInfo.endIndex() = min(end, itemsCount - 1)
-
-    var list: MutableList<LazyMeasuredItem>? = null
+    pinnedItems: List<Int>,
+    consumedScroll: Float,
+    isLookingAhead: Boolean,
+    lastPostLookaheadLayoutInfo: TvLazyListLayoutInfo?
+): List<LazyListMeasuredItem> {
+    var list: MutableList<LazyListMeasuredItem>? = null
 
     var end = visibleItems.last().index
 
-    fun addItem(index: Int) {
-        if (list == null) list = mutableListOf()
-        @Suppress("ExceptionMessage")
-        checkNotNull(list).add(
-            measuredItemProvider.getAndMeasure(DataIndex(index))
-        )
-    }
-
-    if (beyondBoundsInfo.hasIntervals()) {
-        end = maxOf(beyondBoundsInfo.endIndex(), end)
-    }
-
     end = minOf(end + beyondBoundsItemCount, itemsCount - 1)
 
     for (i in visibleItems.last().index + 1..end) {
-        addItem(i)
+        if (list == null) list = mutableListOf()
+        list.add(measuredItemProvider.getAndMeasure(i))
     }
 
-    pinnedItems.fastForEach { item ->
-        val index = itemProvider.findIndexByKey(item.key, item.index)
-        if (index > end && index < itemsCount) {
-            addItem(index)
+    pinnedItems.fastForEach { index ->
+        if (index > end) {
+            if (list == null) list = mutableListOf()
+            list?.add(measuredItemProvider.getAndMeasure(index))
+        }
+    }
+
+    if (isLookingAhead) {
+        // Check if there's any item that needs to be composed based on last postLookaheadLayoutInfo
+        if (lastPostLookaheadLayoutInfo != null &&
+            lastPostLookaheadLayoutInfo.visibleItemsInfo.isNotEmpty()
+        ) {
+            // Find first item with index > end. Note that `visibleItemsInfo.last()` may not have
+            // the largest index as the last few items could be added to animate item placement.
+            val firstItem = lastPostLookaheadLayoutInfo.visibleItemsInfo.run {
+                var found: TvLazyListItemInfo? = null
+                for (i in size - 1 downTo 0) {
+                    if (this[i].index > end && (i == 0 || this[i - 1].index <= end)) {
+                        found = this[i]
+                        break
+                    }
+                }
+                found
+            }
+            val lastVisibleItem = lastPostLookaheadLayoutInfo.visibleItemsInfo.last()
+            if (firstItem != null) {
+                for (i in firstItem.index..lastVisibleItem.index) {
+                    if (list?.fastAny { it.index == i } != null) {
+                        if (list == null) list = mutableListOf()
+                        list?.add(measuredItemProvider.getAndMeasure(i))
+                    }
+                }
+            }
+
+            // Calculate the additional offset to subcompose based on what was shown in the
+            // previous post-loookahead pass and the scroll consumed.
+            val additionalOffset =
+                lastPostLookaheadLayoutInfo.viewportEndOffset - lastVisibleItem.offset -
+                    lastVisibleItem.size - consumedScroll
+            if (additionalOffset > 0) {
+                var index = lastVisibleItem.index + 1
+                var totalOffset = 0
+                while (index < itemsCount && totalOffset < additionalOffset) {
+                    val item = if (index <= end) {
+                        visibleItems.fastFirstOrNull { it.index == index }
+                    } else null
+                        ?: list?.fastFirstOrNull { it.index == index }
+                    if (item != null) {
+                        index++
+                        totalOffset += item.sizeWithSpacings
+                    } else {
+                        if (list == null) list = mutableListOf()
+                        list?.add(measuredItemProvider.getAndMeasure(index))
+                        index++
+                        totalOffset += list!!.last().sizeWithSpacings
+                    }
+                }
+            }
         }
     }
 
     return list ?: emptyList()
 }
 
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-@OptIn(ExperimentalFoundationApi::class)
 private fun createItemsBeforeList(
-    beyondBoundsInfo: LazyListBeyondBoundsInfo,
-    currentFirstItemIndex: DataIndex,
-    measuredItemProvider: LazyMeasuredItemProvider,
-    itemProvider: LazyListItemProvider,
-    itemsCount: Int,
+    currentFirstItemIndex: Int,
+    measuredItemProvider: LazyListMeasuredItemProvider,
     beyondBoundsItemCount: Int,
-    pinnedItems: LazyLayoutPinnedItemList
-): List<LazyMeasuredItem> {
-    fun LazyListBeyondBoundsInfo.startIndex() = min(start, itemsCount - 1)
+    pinnedItems: List<Int>
+): List<LazyListMeasuredItem> {
+    var list: MutableList<LazyListMeasuredItem>? = null
 
-    var list: MutableList<LazyMeasuredItem>? = null
-
-    var start = currentFirstItemIndex.value
-
-    fun addItem(index: Int) {
-        if (list == null) list = mutableListOf()
-        @Suppress("ExceptionMessage")
-        checkNotNull(list).add(measuredItemProvider.getAndMeasure(DataIndex(index)))
-    }
-
-    if (beyondBoundsInfo.hasIntervals()) {
-        start = minOf(beyondBoundsInfo.startIndex(), start)
-    }
+    var start = currentFirstItemIndex
 
     start = maxOf(0, start - beyondBoundsItemCount)
 
-    for (i in currentFirstItemIndex.value - 1 downTo start) {
-        addItem(i)
+    for (i in currentFirstItemIndex - 1 downTo start) {
+        if (list == null) list = mutableListOf()
+        list.add(measuredItemProvider.getAndMeasure(i))
     }
 
-    pinnedItems.fastForEach { item ->
-        val index = itemProvider.findIndexByKey(item.key, item.index)
+    pinnedItems.fastForEach { index ->
         if (index < start) {
-            addItem(index)
+            if (list == null) list = mutableListOf()
+            list?.add(measuredItemProvider.getAndMeasure(index))
         }
     }
 
@@ -430,12 +470,12 @@
 }
 
 /**
- * Calculates [LazyMeasuredItem]s offsets.
+ * Calculates [LazyListMeasuredItem]s offsets.
  */
 private fun calculateItemsOffsets(
-    items: List<LazyMeasuredItem>,
-    extraItemsBefore: List<LazyMeasuredItem>,
-    extraItemsAfter: List<LazyMeasuredItem>,
+    items: List<LazyListMeasuredItem>,
+    extraItemsBefore: List<LazyListMeasuredItem>,
+    extraItemsAfter: List<LazyListMeasuredItem>,
     layoutWidth: Int,
     layoutHeight: Int,
     finalMainAxisOffset: Int,
@@ -446,7 +486,7 @@
     horizontalArrangement: Arrangement.Horizontal?,
     reverseLayout: Boolean,
     density: Density,
-): MutableList<LazyListPositionedItem> {
+): MutableList<LazyListMeasuredItem> {
     val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
     val hasSpareSpace = finalMainAxisOffset < minOf(mainAxisLayoutSize, maxOffset)
     if (hasSpareSpace) {
@@ -454,7 +494,7 @@
     }
 
     val positionedItems =
-        ArrayList<LazyListPositionedItem>(items.size + extraItemsBefore.size + extraItemsAfter.size)
+        ArrayList<LazyListMeasuredItem>(items.size + extraItemsBefore.size + extraItemsAfter.size)
 
     if (hasSpareSpace) {
         require(extraItemsBefore.isEmpty() && extraItemsAfter.isEmpty()) { "no extra items" }
@@ -468,11 +508,19 @@
         }
         val offsets = IntArray(itemsCount) { 0 }
         if (isVertical) {
-            with(requireNotNull(verticalArrangement) { "null verticalArrangement" }) {
+            with(
+                requireNotNull(verticalArrangement) {
+                    "null verticalArrangement when isVertical == true"
+                }
+            ) {
                 density.arrange(mainAxisLayoutSize, sizes, offsets)
             }
         } else {
-            with(requireNotNull(horizontalArrangement) { "null horizontalAlignment" }) {
+            with(
+                requireNotNull(horizontalArrangement) {
+                    "null horizontalArrangement when isVertical == false"
+                }
+            ) {
                 // Enforces Ltr layout direction as it is mirrored with placeRelative later.
                 density.arrange(mainAxisLayoutSize, sizes, LayoutDirection.Ltr, offsets)
             }
@@ -490,23 +538,27 @@
             } else {
                 absoluteOffset
             }
-            positionedItems.add(item.position(relativeOffset, layoutWidth, layoutHeight))
+            item.position(relativeOffset, layoutWidth, layoutHeight)
+            positionedItems.add(item)
         }
     } else {
         var currentMainAxis = itemsScrollOffset
         extraItemsBefore.fastForEach {
             currentMainAxis -= it.sizeWithSpacings
-            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            it.position(currentMainAxis, layoutWidth, layoutHeight)
+            positionedItems.add(it)
         }
 
         currentMainAxis = itemsScrollOffset
         items.fastForEach {
-            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            it.position(currentMainAxis, layoutWidth, layoutHeight)
+            positionedItems.add(it)
             currentMainAxis += it.sizeWithSpacings
         }
 
         extraItemsAfter.fastForEach {
-            positionedItems.add(it.position(currentMainAxis, layoutWidth, layoutHeight))
+            it.position(currentMainAxis, layoutWidth, layoutHeight)
+            positionedItems.add(it)
             currentMainAxis += it.sizeWithSpacings
         }
     }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt
index bc82bee..bbd044f 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasureResult.kt
@@ -26,7 +26,7 @@
 internal class LazyListMeasureResult(
     // properties defining the scroll position:
     /** The new first visible item.*/
-    val firstVisibleItem: LazyMeasuredItem?,
+    val firstVisibleItem: LazyListMeasuredItem?,
     /** The new value for [TvLazyListState.firstVisibleItemScrollOffset].*/
     val firstVisibleItemScrollOffset: Int,
     /** True if there is some space available to continue scrolling in the forward direction.*/
@@ -35,6 +35,8 @@
     val consumedScroll: Float,
     /** MeasureResult defining the layout.*/
     measureResult: MeasureResult,
+    /** The amount of scroll-back that happened due to reaching the end of the list. */
+    val scrollBackAmount: Float,
     // properties representing the info needed for LazyListLayoutInfo:
     /** see [TvLazyListLayoutInfo.visibleItemsInfo] */
     override val visibleItemsInfo: List<TvLazyListItemInfo>,
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItem.kt
new file mode 100644
index 0000000..92e4495
--- /dev/null
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItem.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.foundation.lazy.list
+
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.tv.foundation.ExperimentalTvFoundationApi
+import androidx.tv.foundation.lazy.grid.LazyLayoutAnimateItemModifierNode
+
+/**
+ * Represents one measured item of the lazy list. It can in fact consist of multiple placeables
+ * if the user emit multiple layout nodes in the item callback.
+ */
+internal class LazyListMeasuredItem @ExperimentalTvFoundationApi constructor(
+    override val index: Int,
+    private val placeables: List<Placeable>,
+    val isVertical: Boolean,
+    private val horizontalAlignment: Alignment.Horizontal?,
+    private val verticalAlignment: Alignment.Vertical?,
+    private val layoutDirection: LayoutDirection,
+    private val reverseLayout: Boolean,
+    private val beforeContentPadding: Int,
+    private val afterContentPadding: Int,
+    /**
+     * Extra spacing to be added to [size] aside from the sum of the [placeables] size. It
+     * is usually representing the spacing after the item.
+     */
+    private val spacing: Int,
+    /**
+     * The offset which shouldn't affect any calculations but needs to be applied for the final
+     * value passed into the place() call.
+     */
+    private val visualOffset: IntOffset,
+    override val key: Any,
+    override val contentType: Any?
+) : TvLazyListItemInfo {
+    override var offset: Int = 0
+        private set
+
+    /**
+     * Sum of the main axis sizes of all the inner placeables.
+     */
+    override val size: Int
+
+    /**
+     * Sum of the main axis sizes of all the inner placeables and [spacing].
+     */
+    val sizeWithSpacings: Int
+
+    /**
+     * Max of the cross axis sizes of all the inner placeables.
+     */
+    val crossAxisSize: Int
+
+    private var mainAxisLayoutSize: Int = Unset
+    private var minMainAxisOffset: Int = 0
+    private var maxMainAxisOffset: Int = 0
+
+    // optimized for storing x and y offsets for each placeable one by one.
+    // array's size == placeables.size * 2, first we store x, then y.
+    private val placeableOffsets: IntArray
+
+    init {
+        var mainAxisSize = 0
+        var maxCrossAxis = 0
+        placeables.fastForEach {
+            mainAxisSize += if (isVertical) it.height else it.width
+            maxCrossAxis = maxOf(maxCrossAxis, if (!isVertical) it.height else it.width)
+        }
+        size = mainAxisSize
+        sizeWithSpacings = (size + spacing).coerceAtLeast(0)
+        crossAxisSize = maxCrossAxis
+        placeableOffsets = IntArray(placeables.size * 2)
+    }
+
+    val placeablesCount: Int get() = placeables.size
+
+    fun getParentData(index: Int) = placeables[index].parentData
+
+    /**
+     * Calculates positions for the inner placeables at [offset] main axis position.
+     * If [reverseOrder] is true the inner placeables would be placed in the inverted order.
+     */
+    fun position(
+        offset: Int,
+        layoutWidth: Int,
+        layoutHeight: Int
+    ) {
+        this.offset = offset
+        mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
+        var mainAxisOffset = offset
+        placeables.fastForEachIndexed { index, placeable ->
+            val indexInArray = index * 2
+            if (isVertical) {
+                placeableOffsets[indexInArray] =
+                    requireNotNull(horizontalAlignment) {
+                        "null horizontalAlignment when isVertical == true"
+                    }.align(placeable.width, layoutWidth, layoutDirection)
+                placeableOffsets[indexInArray + 1] = mainAxisOffset
+                mainAxisOffset += placeable.height
+            } else {
+                placeableOffsets[indexInArray] = mainAxisOffset
+                placeableOffsets[indexInArray + 1] =
+                    requireNotNull(verticalAlignment) {
+                        "null verticalAlignment when isVertical == false"
+                    }.align(placeable.height, layoutHeight)
+                mainAxisOffset += placeable.width
+            }
+        }
+        minMainAxisOffset = -beforeContentPadding
+        maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding
+    }
+
+    fun getOffset(index: Int) =
+        IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1])
+
+    fun place(
+        scope: Placeable.PlacementScope,
+        isLookingAhead: Boolean
+    ) = with(scope) {
+        require(mainAxisLayoutSize != Unset) { "position() should be called first" }
+        repeat(placeablesCount) { index ->
+            val placeable = placeables[index]
+            val minOffset = minMainAxisOffset - placeable.mainAxisSize
+            val maxOffset = maxMainAxisOffset
+            var offset = getOffset(index)
+            val animateNode = getParentData(index) as? LazyLayoutAnimateItemModifierNode
+            if (animateNode != null) {
+                if (isLookingAhead) {
+                    // Skip animation in lookahead pass
+                    animateNode.lookaheadOffset = offset
+                } else {
+                    val targetOffset =
+                        if (animateNode.lookaheadOffset !=
+                            LazyLayoutAnimateItemModifierNode.NotInitialized) {
+                            animateNode.lookaheadOffset
+                        } else {
+                            offset
+                        }
+                    val animatedOffset = targetOffset + animateNode.placementDelta
+                    // cancel the animation if current and target offsets are both out of the bounds
+                    if ((targetOffset.mainAxis <= minOffset &&
+                            animatedOffset.mainAxis <= minOffset) ||
+                        (targetOffset.mainAxis >= maxOffset &&
+                            animatedOffset.mainAxis >= maxOffset)
+                    ) {
+                        animateNode.cancelAnimation()
+                    }
+                    offset = animatedOffset
+                }
+            }
+            if (reverseLayout) {
+                offset = offset.copy { mainAxisOffset ->
+                    mainAxisLayoutSize - mainAxisOffset - placeable.mainAxisSize
+                }
+            }
+            offset += visualOffset
+            if (isVertical) {
+                placeable.placeWithLayer(offset)
+            } else {
+                placeable.placeRelativeWithLayer(offset)
+            }
+        }
+    }
+
+    private val IntOffset.mainAxis get() = if (isVertical) y else x
+    private val Placeable.mainAxisSize get() = if (isVertical) height else width
+    private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset =
+        IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y)
+}
+
+private const val Unset = Int.MIN_VALUE
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItemProvider.kt
similarity index 65%
rename from tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt
rename to tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItemProvider.kt
index ce25e61..f400664 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItemProvider.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListMeasuredItemProvider.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2020 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.
@@ -20,19 +20,18 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
+import androidx.tv.foundation.ExperimentalTvFoundationApi
 import androidx.tv.foundation.lazy.layout.LazyLayoutKeyIndexMap
 
 /**
  * Abstracts away the subcomposition from the measuring logic.
  */
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
 @OptIn(ExperimentalFoundationApi::class)
-internal class LazyMeasuredItemProvider @ExperimentalFoundationApi constructor(
+internal abstract class LazyListMeasuredItemProvider @ExperimentalTvFoundationApi constructor(
     constraints: Constraints,
     isVertical: Boolean,
     private val itemProvider: LazyListItemProvider,
-    private val measureScope: LazyLayoutMeasureScope,
-    private val measuredItemFactory: MeasuredItemFactory
+    private val measureScope: LazyLayoutMeasureScope
 ) {
     // the constraints we will measure child with. the main axis is not restricted
     val childConstraints = Constraints(
@@ -42,26 +41,25 @@
 
     /**
      * Used to subcompose items of lazy lists. Composed placeables will be measured with the
-     * correct constraints and wrapped into [LazyMeasuredItem].
+     * correct constraints and wrapped into [LazyListMeasuredItem].
      */
-    fun getAndMeasure(index: DataIndex): LazyMeasuredItem {
-        val key = itemProvider.getKey(index.value)
-        val placeables = measureScope.measure(index.value, childConstraints)
-        return measuredItemFactory.createItem(index, key, placeables)
+    fun getAndMeasure(index: Int): LazyListMeasuredItem {
+        val key = itemProvider.getKey(index)
+        val contentType = itemProvider.getContentType(index)
+        val placeables = measureScope.measure(index, childConstraints)
+        return createItem(index, key, contentType, placeables)
     }
 
     /**
      * Contains the mapping between the key and the index. It could contain not all the items of
      * the list as an optimization.
      */
-    val keyToIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyToIndexMap
-}
+    val keyIndexMap: LazyLayoutKeyIndexMap get() = itemProvider.keyIndexMap
 
-// This interface allows to avoid autoboxing on index param
-internal fun interface MeasuredItemFactory {
-    fun createItem(
-        index: DataIndex,
+    abstract fun createItem(
+        index: Int,
         key: Any,
+        contentType: Any?,
         placeables: List<Placeable>
-    ): LazyMeasuredItem
+    ): LazyListMeasuredItem
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt
index f1c7352..eac05cd 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListScrollPosition.kt
@@ -20,9 +20,8 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutItemProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
+import androidx.tv.foundation.lazy.layout.LazyLayoutNearestRangeState
 
 /**
  * Contains the current scroll position represented by the first visible item index and the first
@@ -33,7 +32,7 @@
     initialIndex: Int = 0,
     initialScrollOffset: Int = 0
 ) {
-    var index by mutableStateOf(DataIndex(initialIndex))
+    var index by mutableIntStateOf(initialIndex)
 
     var scrollOffset by mutableIntStateOf(initialScrollOffset)
         private set
@@ -43,6 +42,12 @@
     /** The last know key of the item at [index] position. */
     private var lastKnownFirstItemKey: Any? = null
 
+    val nearestRangeState = LazyLayoutNearestRangeState(
+        initialIndex,
+        NearestItemsSlidingWindowSize,
+        NearestItemsExtraItemCount
+    )
+
     /**
      * Updates the current scroll position based on the results of the last measurement.
      */
@@ -55,12 +60,8 @@
             hadFirstNotEmptyLayout = true
             val scrollOffset = measureResult.firstVisibleItemScrollOffset
             check(scrollOffset >= 0f) { "scrollOffset should be non-negative ($scrollOffset)" }
-            Snapshot.withoutReadObservation {
-                update(
-                    DataIndex(measureResult.firstVisibleItem?.index ?: 0),
-                    scrollOffset
-                )
-            }
+            val firstIndex = measureResult.firstVisibleItem?.index ?: 0
+            update(firstIndex, scrollOffset)
         }
     }
 
@@ -75,7 +76,7 @@
      * c) there will be not enough items to fill the viewport after the requested index, so we
      * would have to compose few elements before the asked index, changing the first visible item.
      */
-    fun requestPosition(index: DataIndex, scrollOffset: Int) {
+    fun requestPosition(index: Int, scrollOffset: Int) {
         update(index, scrollOffset)
         // clear the stored key as we have a direct request to scroll to [index] position and the
         // next [checkIfFirstVisibleItemWasMoved] shouldn't override this.
@@ -90,27 +91,38 @@
      */
     @Suppress("IllegalExperimentalApiUsage") // TODO(b/233188423): Address before moving to beta
     @ExperimentalFoundationApi
-    fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) {
-        Snapshot.withoutReadObservation {
-            update(
-                DataIndex(itemProvider.findIndexByKey(lastKnownFirstItemKey, index.value)),
-                scrollOffset
-            )
+    fun updateScrollPositionIfTheFirstItemWasMoved(
+        itemProvider: LazyListItemProvider,
+        index: Int
+    ): Int {
+        val newIndex = itemProvider.findIndexByKey(lastKnownFirstItemKey, index)
+        if (index != newIndex) {
+            this.index = newIndex
+            nearestRangeState.update(index)
         }
+        return newIndex
     }
 
-    private fun update(index: DataIndex, scrollOffset: Int) {
-        require(index.value >= 0f) { "Index should be non-negative (${index.value})" }
-        if (index != this.index) {
-            this.index = index
-        }
-        if (scrollOffset != this.scrollOffset) {
-            this.scrollOffset = scrollOffset
-        }
+    private fun update(index: Int, scrollOffset: Int) {
+        require(index >= 0f) { "Index should be non-negative ($index)" }
+        this.index = index
+        nearestRangeState.update(index)
+        this.scrollOffset = scrollOffset
     }
 }
 
 /**
+ * We use the idea of sliding window as an optimization, so user can scroll up to this number of
+ * items until we have to regenerate the key to index map.
+ */
+internal const val NearestItemsSlidingWindowSize = 30
+
+/**
+ * The minimum amount of items near the current first visible item we want to have mapping for.
+ */
+internal const val NearestItemsExtraItemCount = 100
+
+/**
  * Finds a position of the item with the given key in the lists. This logic allows us to
  * detect when there were items added or removed before our current first item.
  */
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
index f5680d1..8e48ebb 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyListState.kt
@@ -16,6 +16,13 @@
 
 package androidx.tv.foundation.lazy.list
 
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.animateTo
+import androidx.compose.animation.core.copy
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.gestures.Orientation
@@ -33,18 +40,19 @@
 import androidx.compose.runtime.saveable.listSaver
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.layout.LayoutCoordinates
-import androidx.compose.ui.layout.OnGloballyPositionedModifier
+import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.layout.Remeasurement
 import androidx.compose.ui.layout.RemeasurementModifier
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.layout.AwaitFirstLayoutModifier
+import androidx.tv.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
 import androidx.tv.foundation.lazy.layout.animateScrollToItem
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
 import kotlin.math.abs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 
 /**
  * Creates a [TvLazyListState] that is remembered across compositions.
@@ -84,6 +92,11 @@
     firstVisibleItemIndex: Int = 0,
     firstVisibleItemScrollOffset: Int = 0
 ) : ScrollableState {
+    internal var hasLookaheadPassOccurred: Boolean = false
+        private set
+    internal var postLookaheadLayoutInfo: TvLazyListLayoutInfo? = null
+        private set
+
     /**
      * The holder class for the current scroll position.
      */
@@ -104,7 +117,7 @@
      * If you need to use it in the composition then consider wrapping the calculation into a
      * derived state in order to only have recompositions when the derived value changes.
      */
-    val firstVisibleItemIndex: Int get() = scrollPosition.index.value
+    val firstVisibleItemIndex: Int get() = scrollPosition.index
 
     /**
      * The scroll offset of the first visible item. Scrolling forward is positive - i.e., the
@@ -152,7 +165,7 @@
     /**
      * Needed for [animateScrollToItem].  Updated on every measure.
      */
-    internal var density: Density by mutableStateOf(Density(1f, 1f))
+    internal var density: Density = Density(1f, 1f)
 
     /**
      * The ScrollableController instance. We keep it as we need to call stopAnimation on it once
@@ -193,7 +206,7 @@
      * The [Remeasurement] object associated with our layout. It allows us to remeasure
      * synchronously during scroll.
      */
-    internal var remeasurement: Remeasurement? by mutableStateOf(null)
+    internal var remeasurement: Remeasurement? = null
         private set
     /**
      * The modifier which provides [remeasurement].
@@ -210,18 +223,22 @@
      */
     internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
 
-    internal var placementAnimator by mutableStateOf<LazyListItemPlacementAnimator?>(null)
+    internal val placementAnimator = LazyListItemPlacementAnimator()
+
+    internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
 
     /**
      * Constraints passed to the prefetcher for premeasuring the prefetched items.
      */
-    internal var premeasureConstraints by mutableStateOf(Constraints())
+    internal var premeasureConstraints = Constraints()
 
     /**
      * Stores currently pinned items which are always composed.
      */
     internal val pinnedItems = LazyLayoutPinnedItemList()
 
+    internal val nearestRange: IntRange by scrollPosition.nearestRangeState
+
     /**
      * Instantly brings the item at [index] to the top of the viewport, offset by [scrollOffset]
      * pixels.
@@ -242,9 +259,9 @@
     }
 
     internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
-        scrollPosition.requestPosition(DataIndex(index), scrollOffset)
+        scrollPosition.requestPosition(index, scrollOffset)
         // placement animation is not needed because we snap into a new position.
-        placementAnimator?.reset()
+        placementAnimator.reset()
         remeasurement?.forceRemeasure()
     }
 
@@ -379,18 +396,69 @@
     /**
      *  Updates the state with the new calculated scroll position and consumed scroll.
      */
-    internal fun applyMeasureResult(result: LazyListMeasureResult) {
-        scrollPosition.updateFromMeasureResult(result)
-        scrollToBeConsumed -= result.consumedScroll
-        layoutInfoState.value = result
+    internal fun applyMeasureResult(result: LazyListMeasureResult, isLookingAhead: Boolean) {
+        if (!isLookingAhead && hasLookaheadPassOccurred) {
+            // If there was already a lookahead pass, record this result as postLookahead result
+            postLookaheadLayoutInfo = result
+        } else {
+            if (isLookingAhead) {
+                hasLookaheadPassOccurred = true
+            }
+            scrollPosition.updateFromMeasureResult(result)
+            scrollToBeConsumed -= result.consumedScroll
+            layoutInfoState.value = result
 
-        canScrollForward = result.canScrollForward
-        canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
-            result.firstVisibleItemScrollOffset != 0
+            canScrollForward = result.canScrollForward
+            canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
+                result.firstVisibleItemScrollOffset != 0
 
-        numMeasurePasses++
+            if (isLookingAhead) updateScrollDeltaForPostLookahead(result.scrollBackAmount)
+            numMeasurePasses++
 
-        cancelPrefetchIfVisibleItemsChanged(result)
+            cancelPrefetchIfVisibleItemsChanged(result)
+        }
+    }
+
+    internal var coroutineScope: CoroutineScope? = null
+
+    internal val scrollDeltaBetweenPasses: Float
+        get() = _scrollDeltaBetweenPasses.value
+
+    private var _scrollDeltaBetweenPasses: AnimationState<Float, AnimationVector1D> =
+        AnimationState(Float.VectorConverter, 0f, 0f)
+
+    // Updates the scroll delta between lookahead & post-lookahead pass
+    private fun updateScrollDeltaForPostLookahead(delta: Float) {
+        if (delta <= with(density) { DeltaThresholdForScrollAnimation.toPx() }) {
+            // If the delta is within the threshold, scroll by the delta amount instead of animating
+            return
+        }
+
+        // Scroll delta is updated during lookahead, we don't need to trigger lookahead when
+        // the delta changes.
+        Snapshot.withoutReadObservation {
+            val currentDelta = _scrollDeltaBetweenPasses.value
+
+            if (_scrollDeltaBetweenPasses.isRunning) {
+                _scrollDeltaBetweenPasses = _scrollDeltaBetweenPasses.copy(currentDelta - delta)
+                coroutineScope?.launch {
+                    _scrollDeltaBetweenPasses.animateTo(
+                        0f,
+                        spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
+                        true
+                    )
+                }
+            } else {
+                _scrollDeltaBetweenPasses = AnimationState(Float.VectorConverter, -delta)
+                coroutineScope?.launch {
+                    _scrollDeltaBetweenPasses.animateTo(
+                        0f,
+                        spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
+                        true
+                    )
+                }
+            }
+        }
     }
 
     /**
@@ -398,9 +466,10 @@
      * items added or removed before our current first visible item and keep this item
      * as the first visible one even given that its index has been changed.
      */
-    internal fun updateScrollPositionIfTheFirstItemWasMoved(itemProvider: LazyListItemProvider) {
-        scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider)
-    }
+    internal fun updateScrollPositionIfTheFirstItemWasMoved(
+        itemProvider: LazyListItemProvider,
+        firstItemIndex: Int = Snapshot.withoutReadObservation { scrollPosition.index }
+    ): Int = scrollPosition.updateScrollPositionIfTheFirstItemWasMoved(itemProvider, firstItemIndex)
 
     companion object {
         /**
@@ -418,6 +487,8 @@
     }
 }
 
+private val DeltaThresholdForScrollAnimation = 1.dp
+
 private object EmptyLazyListLayoutInfo : TvLazyListLayoutInfo {
     override val visibleItemsInfo = emptyList<TvLazyListItemInfo>()
     override val viewportStartOffset = 0
@@ -430,24 +501,3 @@
     override val afterContentPadding = 0
     override val mainAxisItemSpacing = 0
 }
-
-internal class AwaitFirstLayoutModifier : OnGloballyPositionedModifier {
-    private var wasPositioned = false
-    private var continuation: Continuation<Unit>? = null
-
-    suspend fun waitForFirstLayout() {
-        if (!wasPositioned) {
-            val oldContinuation = continuation
-            suspendCoroutine<Unit> { continuation = it }
-            oldContinuation?.resume(Unit)
-        }
-    }
-
-    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
-        if (!wasPositioned) {
-            wasPositioned = true
-            continuation?.resume(Unit)
-            continuation = null
-        }
-    }
-}
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt
deleted file mode 100644
index b1326406..0000000
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/LazyMeasuredItem.kt
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.tv.foundation.lazy.list
-
-import androidx.compose.animation.core.FiniteAnimationSpec
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.util.fastForEach
-
-/**
- * Represents one measured item of the lazy list. It can in fact consist of multiple placeables
- * if the user emit multiple layout nodes in the item callback.
- */
-@Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta
-internal class LazyMeasuredItem @ExperimentalFoundationApi constructor(
-    val index: Int,
-    private val placeables: List<Placeable>,
-    private val isVertical: Boolean,
-    private val horizontalAlignment: Alignment.Horizontal?,
-    private val verticalAlignment: Alignment.Vertical?,
-    private val layoutDirection: LayoutDirection,
-    private val reverseLayout: Boolean,
-    private val beforeContentPadding: Int,
-    private val afterContentPadding: Int,
-    private val placementAnimator: LazyListItemPlacementAnimator,
-    /**
-     * Extra spacing to be added to [size] aside from the sum of the [placeables] size. It
-     * is usually representing the spacing after the item.
-     */
-    private val spacing: Int,
-    /**
-     * The offset which shouldn't affect any calculations but needs to be applied for the final
-     * value passed into the place() call.
-     */
-    private val visualOffset: IntOffset,
-    val key: Any,
-) {
-    /**
-     * Sum of the main axis sizes of all the inner placeables.
-     */
-    val size: Int
-
-    /**
-     * Sum of the main axis sizes of all the inner placeables and [spacing].
-     */
-    val sizeWithSpacings: Int
-
-    /**
-     * Max of the cross axis sizes of all the inner placeables.
-     */
-    val crossAxisSize: Int
-
-    init {
-        var mainAxisSize = 0
-        var maxCrossAxis = 0
-        placeables.fastForEach {
-            mainAxisSize += if (isVertical) it.height else it.width
-            maxCrossAxis = maxOf(maxCrossAxis, if (!isVertical) it.height else it.width)
-        }
-        size = mainAxisSize
-        sizeWithSpacings = (size + spacing).coerceAtLeast(0)
-        crossAxisSize = maxCrossAxis
-    }
-
-    /**
-     * Calculates positions for the inner placeables at [offset] main axis position.
-     * If [reverseOrder] is true the inner placeables would be placed in the inverted order.
-     */
-    fun position(
-        offset: Int,
-        layoutWidth: Int,
-        layoutHeight: Int
-    ): LazyListPositionedItem {
-        val wrappers = mutableListOf<LazyListPlaceableWrapper>()
-        val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
-        var mainAxisOffset = offset
-        placeables.fastForEach {
-            val placeableOffset = if (isVertical) {
-                val x = requireNotNull(horizontalAlignment) { "null horizontalAlignment" }
-                    .align(it.width, layoutWidth, layoutDirection)
-                IntOffset(x, mainAxisOffset)
-            } else {
-                val y = requireNotNull(verticalAlignment) { "null verticalAlignment" }
-                    .align(it.height, layoutHeight)
-                IntOffset(mainAxisOffset, y)
-            }
-            mainAxisOffset += if (isVertical) it.height else it.width
-            wrappers.add(LazyListPlaceableWrapper(placeableOffset, it))
-        }
-        return LazyListPositionedItem(
-            offset = offset,
-            index = this.index,
-            key = key,
-            size = size,
-            minMainAxisOffset = -beforeContentPadding,
-            maxMainAxisOffset = mainAxisLayoutSize + afterContentPadding,
-            isVertical = isVertical,
-            wrappers = wrappers,
-            placementAnimator = placementAnimator,
-            visualOffset = visualOffset,
-            reverseLayout = reverseLayout,
-            mainAxisLayoutSize = mainAxisLayoutSize
-        )
-    }
-}
-
-internal class LazyListPositionedItem(
-    override val offset: Int,
-    override val index: Int,
-    override val key: Any,
-    override val size: Int,
-    private val minMainAxisOffset: Int,
-    private val maxMainAxisOffset: Int,
-    private val isVertical: Boolean,
-    private val wrappers: List<LazyListPlaceableWrapper>,
-    private val placementAnimator: LazyListItemPlacementAnimator,
-    private val visualOffset: IntOffset,
-    private val reverseLayout: Boolean,
-    private val mainAxisLayoutSize: Int
-) : TvLazyListItemInfo {
-    val placeablesCount: Int get() = wrappers.size
-
-    fun getOffset(index: Int) = wrappers[index].offset
-
-    fun getMainAxisSize(index: Int) = wrappers[index].placeable.mainAxisSize
-
-    @Suppress("UNCHECKED_CAST")
-    fun getAnimationSpec(index: Int) =
-        wrappers[index].placeable.parentData as? FiniteAnimationSpec<IntOffset>?
-
-    val hasAnimations = run {
-        repeat(placeablesCount) { index ->
-            if (getAnimationSpec(index) != null) {
-                return@run true
-            }
-        }
-        false
-    }
-
-    fun place(
-        scope: Placeable.PlacementScope,
-    ) = with(scope) {
-        repeat(placeablesCount) { index ->
-            val placeable = wrappers[index].placeable
-            val minOffset = minMainAxisOffset - placeable.mainAxisSize
-            val maxOffset = maxMainAxisOffset
-            val offset = if (getAnimationSpec(index) != null) {
-                placementAnimator.getAnimatedOffset(
-                    key, index, minOffset, maxOffset, getOffset(index)
-                )
-            } else {
-                getOffset(index)
-            }
-            val reverseLayoutAwareOffset = if (reverseLayout) {
-                offset.copy { mainAxisOffset ->
-                    mainAxisLayoutSize - mainAxisOffset - placeable.mainAxisSize
-                }
-            } else {
-                offset
-            }
-            if (isVertical) {
-                placeable.placeWithLayer(reverseLayoutAwareOffset + visualOffset)
-            } else {
-                placeable.placeRelativeWithLayer(reverseLayoutAwareOffset + visualOffset)
-            }
-        }
-    }
-
-    private val Placeable.mainAxisSize get() = if (isVertical) height else width
-    private inline fun IntOffset.copy(mainAxisMap: (Int) -> Int): IntOffset =
-        IntOffset(if (isVertical) x else mainAxisMap(x), if (isVertical) mainAxisMap(y) else y)
-}
-
-internal class LazyListPlaceableWrapper(
-    val offset: IntOffset,
-    val placeable: Placeable
-)
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt
index 55274f8..5081392 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemInfo.kt
@@ -22,7 +22,7 @@
  *
  * @see TvLazyListLayoutInfo
  */
-interface TvLazyListItemInfo {
+sealed interface TvLazyListItemInfo {
     /**
      * The index of the item in the list.
      */
@@ -43,4 +43,9 @@
      * slot for the item then this size will be calculated as the sum of their sizes.
      */
     val size: Int
+
+    /**
+     * The content type of the item which was passed to the item() or items() function.
+     */
+    val contentType: Any?
 }
diff --git a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt
index 50a37e6..b6ae31a 100644
--- a/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt
+++ b/tv/tv-foundation/src/main/java/androidx/tv/foundation/lazy/list/TvLazyListItemScopeImpl.kt
@@ -18,95 +18,127 @@
 
 import androidx.compose.animation.core.FiniteAnimationSpec
 import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.runtime.IntState
+import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.LayoutModifier
 import androidx.compose.ui.layout.Measurable
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.MeasureScope
-import androidx.compose.ui.layout.ParentDataModifier
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.ParentDataModifierNode
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.InspectorValueInfo
-import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
+import androidx.tv.foundation.lazy.grid.LazyLayoutAnimateItemModifierNode
 import kotlin.math.roundToInt
 
 internal class TvLazyListItemScopeImpl : TvLazyListItemScope {
-
-    private val maxWidthState = mutableIntStateOf(Int.MAX_VALUE)
-    private val maxHeightState = mutableIntStateOf(Int.MAX_VALUE)
+    private var maxWidthState = mutableIntStateOf(Int.MAX_VALUE)
+    private var maxHeightState = mutableIntStateOf(Int.MAX_VALUE)
     fun setMaxSize(width: Int, height: Int) {
         maxWidthState.intValue = width
         maxHeightState.intValue = height
     }
 
     override fun Modifier.fillParentMaxSize(fraction: Float) = then(
-        ParentSizeModifier(
+        ParentSizeElement(
             widthState = maxWidthState,
             heightState = maxHeightState,
             fraction = fraction,
-            inspectorInfo = debugInspectorInfo {
-                name = "fillParentMaxSize"
-                value = fraction
-            }
+            inspectorName = "fillParentMaxSize"
         )
     )
 
     override fun Modifier.fillParentMaxWidth(fraction: Float) = then(
-        ParentSizeModifier(
+        ParentSizeElement(
             widthState = maxWidthState,
             fraction = fraction,
-            inspectorInfo = debugInspectorInfo {
-                name = "fillParentMaxWidth"
-                value = fraction
-            }
+            inspectorName = "fillParentMaxWidth"
         )
     )
 
     override fun Modifier.fillParentMaxHeight(fraction: Float) = then(
-        ParentSizeModifier(
+        ParentSizeElement(
             heightState = maxHeightState,
             fraction = fraction,
-            inspectorInfo = debugInspectorInfo {
-                name = "fillParentMaxHeight"
-                value = fraction
-            }
+            inspectorName = "fillParentMaxHeight"
         )
     )
 
-    @Suppress("IllegalExperimentalApiUsage") // TODO(b/233188423): Address before moving to beta
     @ExperimentalFoundationApi
     override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec<IntOffset>) =
-        this.then(AnimateItemPlacementModifier(animationSpec, debugInspectorInfo {
-            name = "animateItemPlacement"
-            value = animationSpec
-        }))
+        this then AnimateItemPlacementElement(animationSpec)
 }
 
-private class ParentSizeModifier(
+private class ParentSizeElement(
     val fraction: Float,
-    inspectorInfo: InspectorInfo.() -> Unit,
-    val widthState: IntState? = null,
-    val heightState: IntState? = null,
-) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
+    val widthState: State<Int>? = null,
+    val heightState: State<Int>? = null,
+    val inspectorName: String
+) : ModifierNodeElement<ParentSizeNode>() {
+    override fun create(): ParentSizeNode {
+        return ParentSizeNode(
+            fraction = fraction,
+            widthState = widthState,
+            heightState = heightState
+        )
+    }
+
+    override fun update(node: ParentSizeNode) {
+        node.fraction = fraction
+        node.widthState = widthState
+        node.heightState = heightState
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is ParentSizeNode) return false
+        return fraction == other.fraction &&
+            widthState == other.widthState &&
+            heightState == other.heightState
+    }
+
+    override fun hashCode(): Int {
+        var result = widthState?.hashCode() ?: 0
+        result = 31 * result + (heightState?.hashCode() ?: 0)
+        result = 31 * result + fraction.hashCode()
+        return result
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = inspectorName
+        value = fraction
+    }
+}
+
+private class ParentSizeNode(
+    var fraction: Float,
+    var widthState: State<Int>? = null,
+    var heightState: State<Int>? = null,
+) : LayoutModifierNode, Modifier.Node() {
 
     override fun MeasureScope.measure(
         measurable: Measurable,
         constraints: Constraints
     ): MeasureResult {
-        val width = if (widthState != null && widthState.intValue != Constraints.Infinity) {
-            (widthState.intValue * fraction).roundToInt()
-        } else {
-            Constraints.Infinity
-        }
-        val height = if (heightState != null && heightState.intValue != Constraints.Infinity) {
-            (heightState.intValue * fraction).roundToInt()
-        } else {
-            Constraints.Infinity
-        }
+        val width = widthState?.let {
+            if (it.value != Constraints.Infinity) {
+                (it.value * fraction).roundToInt()
+            } else {
+                Constraints.Infinity
+            }
+        } ?: Constraints.Infinity
+
+        val height = heightState?.let {
+            if (it.value != Constraints.Infinity) {
+                (it.value * fraction).roundToInt()
+            } else {
+                Constraints.Infinity
+            }
+        } ?: Constraints.Infinity
         val childConstraints = Constraints(
             minWidth = if (width != Constraints.Infinity) width else constraints.minWidth,
             minHeight = if (height != Constraints.Infinity) height else constraints.minHeight,
@@ -118,36 +150,39 @@
             placeable.place(0, 0)
         }
     }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other !is ParentSizeModifier) return false
-        return widthState == other.widthState &&
-            heightState == other.heightState &&
-            fraction == other.fraction
-    }
-
-    override fun hashCode(): Int {
-        var result = widthState?.hashCode() ?: 0
-        result = 31 * result + (heightState?.hashCode() ?: 0)
-        result = 31 * result + fraction.hashCode()
-        return result
-    }
 }
 
-private class AnimateItemPlacementModifier(
-    val animationSpec: FiniteAnimationSpec<IntOffset>,
-    inspectorInfo: InspectorInfo.() -> Unit,
-) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
-    override fun Density.modifyParentData(parentData: Any?): Any = animationSpec
+private class AnimateItemPlacementElement(
+    val animationSpec: FiniteAnimationSpec<IntOffset>
+) : ModifierNodeElement<AnimateItemPlacementNode>() {
+
+    override fun create(): AnimateItemPlacementNode = AnimateItemPlacementNode(animationSpec)
+
+    override fun update(node: AnimateItemPlacementNode) {
+        node.delegatingNode.placementAnimationSpec = animationSpec
+    }
 
     override fun equals(other: Any?): Boolean {
         if (this === other) return true
-        if (other !is AnimateItemPlacementModifier) return false
+        if (other !is AnimateItemPlacementElement) return false
         return animationSpec != other.animationSpec
     }
 
     override fun hashCode(): Int {
         return animationSpec.hashCode()
     }
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "animateItemPlacement"
+        value = animationSpec
+    }
+}
+
+private class AnimateItemPlacementNode(
+    animationSpec: FiniteAnimationSpec<IntOffset>
+) : DelegatingNode(), ParentDataModifierNode {
+
+    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(animationSpec))
+
+    override fun Density.modifyParentData(parentData: Any?): Any = delegatingNode
 }
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
index 677dca7..7a6eba0 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/lazy/ScalingLazyColumn.kt
@@ -34,11 +34,11 @@
 import androidx.compose.foundation.lazy.LazyListScope
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -58,7 +58,6 @@
 import androidx.compose.ui.unit.offset
 import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
 import androidx.wear.compose.foundation.LocalReduceMotion
-import kotlinx.coroutines.launch
 
 /**
  * Receiver scope which is used by [ScalingLazyColumn].
@@ -396,7 +395,6 @@
             state.reverseLayout.value = reverseLayout
             state.localInspectionMode.value = LocalInspectionMode.current
 
-            val coroutineScope = rememberCoroutineScope()
             LazyColumn(
                 modifier = Modifier
                     .clipToBounds()
@@ -408,9 +406,6 @@
                             layoutInfo.readyForInitialScroll
                         ) {
                             initialized = true
-                            coroutineScope.launch {
-                                state.scrollToInitialItem()
-                            }
                         }
                     },
                 horizontalAlignment = horizontalAlignment,
@@ -448,6 +443,11 @@
                     }
                 }
             }
+            if (initialized) {
+                LaunchedEffect(state) {
+                    state.scrollToInitialItem()
+                }
+            }
         }
     }
 }
diff --git a/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/HorizontalPageIndicator.kt b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/HorizontalPageIndicator.kt
new file mode 100644
index 0000000..c47701d
--- /dev/null
+++ b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/HorizontalPageIndicator.kt
@@ -0,0 +1,164 @@
+/*
+ * 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.materialcore
+
+import androidx.annotation.RestrictTo
+import kotlin.math.abs
+
+/**
+ * Represents an internal state of pageIndicator. This state is responsible for keeping and
+ * recalculating alpha and size parameters of each indicator, and selected indicators as well.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class PagesState(
+    val totalPages: Int,
+    val pagesOnScreen: Int
+) {
+    // Sizes and alphas of first and last indicators on the screen. Used to show that there're more
+    // pages on the left or on the right, and also for smooth transitions
+    private var firstAlpha = 1f
+    private var lastAlpha = 0f
+    private var firstSize = 1f
+    private var secondSize = 1f
+    private var lastSize = 1f
+    private var lastButOneSize = 1f
+
+    private var smoothProgress = 0f
+
+    // An offset in pages, basically meaning how many pages are hidden to the left.
+    private var hiddenPagesToTheLeft = 0
+
+    // A default size of spacers - invisible items to the left and to the right of
+    // visible indicators, used for smooth transitions
+
+    // Current visible position on the screen.
+    var visibleDotIndex = 0
+        private set
+
+    // A size of a left spacer used for smooth transitions
+    val leftSpacerSizeRatio
+        get() = 1 - smoothProgress
+
+    // A size of a right spacer used for smooth transitions
+    val rightSpacerSizeRatio
+        get() = smoothProgress
+
+    /**
+     * Depending on the page index, return an alpha for this indicator
+     *
+     * @param page Page index
+     * @return An alpha of page index- in range 0..1
+     */
+    fun alpha(page: Int): Float =
+        when (page) {
+            0 -> firstAlpha
+            pagesOnScreen -> lastAlpha
+            else -> 1f
+        }
+
+    /**
+     * Depending on the page index, return a size ratio for this indicator
+     *
+     * @param page Page index
+     * @return An size ratio for page index - in range 0..1
+     */
+    fun sizeRatio(page: Int): Float =
+        when (page) {
+            0 -> firstSize
+            1 -> secondSize
+            pagesOnScreen - 1 -> lastButOneSize
+            pagesOnScreen -> lastSize
+            else -> 1f
+        }
+
+    /**
+     * Returns a value in the range 0..1 where 0 is unselected state, and 1 is selected.
+     * Used to show a smooth transition between page indicator items.
+     */
+    fun calculateSelectedRatio(targetPage: Int, offset: Float): Float =
+        (1 - abs(visibleDotIndex + offset - targetPage)).coerceAtLeast(0f)
+
+    // Main function responsible for recalculation of all parameters regarding
+    // to the [selectedPage] and [offset]
+    fun recalculateState(selectedPage: Int, offset: Float) {
+        val pageWithOffset = selectedPage + offset
+
+        // Calculating offsetInPages relating to the [selectedPage].
+
+        // For example, for [selectedPage] = 4 we will see this picture :
+        // O O O O X o. [offsetInPages] will be 0.
+        // But when [selectedPage] will be incremented to 5, it will be seen as
+        // o O O O X o, with [offsetInPages] = 1
+        if (selectedPage > hiddenPagesToTheLeft + pagesOnScreen - 2) {
+            // Set an offset as a difference between current page and pages on the screen,
+            // except if this is not the last page - then offsetInPages is not changed
+            hiddenPagesToTheLeft = (selectedPage - (pagesOnScreen - 2))
+                .coerceAtMost(totalPages - pagesOnScreen)
+        } else if (pageWithOffset <= hiddenPagesToTheLeft) {
+            hiddenPagesToTheLeft = (selectedPage - 1).coerceAtLeast(0)
+        }
+
+        // Condition for scrolling to the right. A smooth scroll to the right is only triggered
+        // when we have more than 2 pages to the right, and currently we're on the right edge.
+        // For example -> o O O O X o -> a small "o" shows that there're more pages to the right
+        val scrolledToTheRight = pageWithOffset > hiddenPagesToTheLeft + pagesOnScreen - 2 &&
+            pageWithOffset < totalPages - 2
+
+        // Condition for scrolling to the left. A smooth scroll to the left is only triggered
+        // when we have more than 2 pages to the left, and currently we're on the left edge.
+        // For example -> o X O O O o -> a small "o" shows that there're more pages to the left
+        val scrolledToTheLeft = pageWithOffset > 1 && pageWithOffset < hiddenPagesToTheLeft + 1
+
+        smoothProgress = if (scrolledToTheLeft || scrolledToTheRight) offset else 0f
+
+        // Calculating exact parameters for border indicators like [firstAlpha], [lastSize], etc.
+        firstAlpha = 1 - smoothProgress
+        lastAlpha = smoothProgress
+        secondSize = 1 - 0.5f * smoothProgress
+
+        // Depending on offsetInPages we'll either show a shrinked first indicator, or full-size
+        firstSize = if (hiddenPagesToTheLeft == 0 ||
+            hiddenPagesToTheLeft == 1 && scrolledToTheLeft
+        ) {
+            1 - smoothProgress
+        } else {
+            0.5f * (1 - smoothProgress)
+        }
+
+        // Depending on offsetInPages and other parameters, we'll either show a shrinked
+        // last indicator, or full-size
+        lastSize =
+            if (hiddenPagesToTheLeft == totalPages - pagesOnScreen - 1 && scrolledToTheRight ||
+                hiddenPagesToTheLeft == totalPages - pagesOnScreen && scrolledToTheLeft
+            ) {
+                smoothProgress
+            } else {
+                0.5f * smoothProgress
+            }
+
+        lastButOneSize = if (scrolledToTheRight || scrolledToTheLeft) {
+            0.5f * (1 + smoothProgress)
+        } else if (hiddenPagesToTheLeft < totalPages - pagesOnScreen) 0.5f else 1f
+
+        // A visibleDot represents a currently selected page on the screen
+        // As we scroll to the left, we add an invisible indicator to the left, shifting all other
+        // indicators to the right. The shift is only possible when a visibleDot = 1,
+        // thus we have to leave it at 1 as we always add a positive offset
+        visibleDotIndex = if (scrolledToTheLeft) 1
+        else selectedPage - hiddenPagesToTheLeft
+    }
+}
diff --git a/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Resources.kt b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Resources.kt
index 6035f10..d2696f3 100644
--- a/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Resources.kt
+++ b/wear/compose/compose-material-core/src/main/java/androidx/wear/compose/materialcore/Resources.kt
@@ -19,6 +19,7 @@
 import androidx.annotation.RestrictTo
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.unit.LayoutDirection
 
@@ -30,3 +31,12 @@
         layoutDirection == LayoutDirection.Rtl
     }
 }
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@Composable
+fun isRoundDevice(): Boolean {
+    val configuration = LocalConfiguration.current
+    return remember(configuration) {
+        configuration.isScreenRound
+    }
+}
diff --git a/wear/compose/compose-material/src/test/kotlin/androidx/wear/compose/material/HorizontalPageIndicatorTest.kt b/wear/compose/compose-material-core/src/test/kotlin/androidx/wear/compose/materialcore/HorizontalPageIndicatorTest.kt
similarity index 97%
rename from wear/compose/compose-material/src/test/kotlin/androidx/wear/compose/material/HorizontalPageIndicatorTest.kt
rename to wear/compose/compose-material-core/src/test/kotlin/androidx/wear/compose/materialcore/HorizontalPageIndicatorTest.kt
index 2f5f308..c27eb62 100644
--- a/wear/compose/compose-material/src/test/kotlin/androidx/wear/compose/material/HorizontalPageIndicatorTest.kt
+++ b/wear/compose/compose-material-core/src/test/kotlin/androidx/wear/compose/materialcore/HorizontalPageIndicatorTest.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,10 +14,10 @@
  * limitations under the License.
  */
 
-package androidx.wear.compose.material
+package androidx.wear.compose.materialcore
 
 import androidx.compose.ui.util.lerp
-import org.junit.Assert.assertEquals
+import org.junit.Assert
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -513,8 +513,8 @@
         testDots.forEachIndexed { index, testDot ->
             testDot(index, testDot, offset)
         }
-        assertEquals("Left spacer:", leftSpacerSize, leftSpacerSizeRatio)
-        assertEquals("Right spacer", rightSpacerSize, rightSpacerSizeRatio)
+        Assert.assertEquals("Left spacer:", leftSpacerSize, leftSpacerSizeRatio)
+        Assert.assertEquals("Right spacer", rightSpacerSize, rightSpacerSizeRatio)
     }
 
     private fun PagesState.testDot(
@@ -523,13 +523,13 @@
         offset: Float
     ) {
 
-        assertEquals("Page $index, alpha:", testDot.alpha lerp offset, alpha(index))
-        assertEquals(
+        Assert.assertEquals("Page $index, alpha:", testDot.alpha lerp offset, alpha(index))
+        Assert.assertEquals(
             "Page $index, size ratio:",
             testDot.sizeRatio lerp offset,
             sizeRatio(index)
         )
-        assertEquals(
+        Assert.assertEquals(
             "Page $index, select ratio:",
             testDot.selectedRatio lerp offset,
             calculateSelectedRatio(index, offset),
diff --git a/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/ToggleButtonTest.kt b/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/ToggleButtonTest.kt
index ae4e264..84db9176 100644
--- a/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/ToggleButtonTest.kt
+++ b/wear/compose/compose-material/src/androidTest/kotlin/androidx/wear/compose/material/ToggleButtonTest.kt
@@ -767,14 +767,11 @@
     setContentWithTheme {
         background = MaterialTheme.colors.surface
         buttonColor = MaterialTheme.colors.primary
-        Box(Modifier.background(background)) {
-            content(
-                Modifier
-                    .testTag(TEST_TAG)
-                    .padding(padding)
-                    .background(background)
-            )
-        }
+        content(
+            Modifier
+                .testTag(TEST_TAG)
+                .padding(padding)
+                .background(background))
     }
 
     onNodeWithTag(TEST_TAG)
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Button.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Button.kt
index b298e04..bc77f65 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Button.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Button.kt
@@ -16,21 +16,31 @@
 package androidx.wear.compose.material
 
 import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.defaultMinSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredSize
 import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ripple.rememberRipple
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 
@@ -154,21 +164,40 @@
     border: ButtonBorder = ButtonDefaults.buttonBorder(),
     content: @Composable BoxScope.() -> Unit,
 ) {
-    androidx.wear.compose.materialcore.Button(
-        onClick = onClick,
-        modifier = modifier,
-        enabled = enabled,
-        backgroundColor = { colors.backgroundColor(it) },
-        interactionSource = interactionSource,
-        shape = shape,
-        border = { border.borderStroke(enabled = it) },
-        buttonSize = ButtonDefaults.DefaultButtonSize,
-        content = provideScopeContent(
-            colors.contentColor(enabled = enabled),
-            MaterialTheme.typography.button,
-            content
-        )
-    )
+    val borderStroke = border.borderStroke(enabled = enabled).value
+    Box(
+        contentAlignment = Alignment.Center,
+        modifier = modifier
+            .defaultMinSize(
+                minWidth = ButtonDefaults.DefaultButtonSize,
+                minHeight = ButtonDefaults.DefaultButtonSize
+            )
+            .then(
+                if (borderStroke != null) Modifier.border(border = borderStroke, shape = shape)
+                else Modifier
+            )
+            .clip(shape)
+            .clickable(
+                onClick = onClick,
+                enabled = enabled,
+                role = Role.Button,
+                interactionSource = interactionSource,
+                indication = rememberRipple(),
+            )
+            .background(
+                color = colors.backgroundColor(enabled = enabled).value,
+                shape = shape
+            )
+    ) {
+        val contentColor = colors.contentColor(enabled = enabled).value
+        CompositionLocalProvider(
+            LocalContentColor provides contentColor,
+            LocalContentAlpha provides contentColor.alpha,
+            LocalTextStyle provides MaterialTheme.typography.button,
+        ) {
+            content()
+        }
+    }
 }
 
 /**
@@ -334,23 +363,40 @@
     border: ButtonBorder = ButtonDefaults.buttonBorder(),
     content: @Composable BoxScope.() -> Unit,
 ) {
-    androidx.wear.compose.materialcore.Button(
-        onClick = onClick,
+    val borderStroke = border.borderStroke(enabled).value
+    Box(
+        contentAlignment = Alignment.Center,
         modifier = modifier
+            .clip(shape)
+            .clickable(
+                onClick = onClick,
+                enabled = enabled,
+                role = Role.Button,
+                interactionSource = interactionSource,
+                indication = rememberRipple()
+            )
             .padding(backgroundPadding)
-            .requiredSize(ButtonDefaults.ExtraSmallButtonSize),
-        enabled = enabled,
-        backgroundColor = { colors.backgroundColor(it) },
-        interactionSource = interactionSource,
-        shape = shape,
-        border = { border.borderStroke(it) },
-        buttonSize = ButtonDefaults.ExtraSmallButtonSize,
-        content = provideScopeContent(
-            colors.contentColor(enabled = enabled),
-            MaterialTheme.typography.button,
-            content
-        )
-    )
+            .requiredSize(ButtonDefaults.ExtraSmallButtonSize)
+            .then(
+                if (borderStroke != null) Modifier.border(
+                    border = borderStroke,
+                    shape = shape
+                ) else Modifier
+            )
+            .background(
+                color = colors.backgroundColor(enabled = enabled).value,
+                shape = shape
+            )
+    ) {
+        val contentColor = colors.contentColor(enabled = enabled).value
+        CompositionLocalProvider(
+            LocalContentColor provides contentColor,
+            LocalContentAlpha provides contentColor.alpha,
+            LocalTextStyle provides MaterialTheme.typography.button,
+        ) {
+            content()
+        }
+    }
 }
 
 /**
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/HorizontalPageIndicator.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/HorizontalPageIndicator.kt
index abbc1e5..42faaf2 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/HorizontalPageIndicator.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/HorizontalPageIndicator.kt
@@ -43,8 +43,9 @@
 import androidx.wear.compose.foundation.CurvedLayout
 import androidx.wear.compose.foundation.curvedComposable
 import androidx.wear.compose.material.PageIndicatorDefaults.MaxNumberOfIndicators
+import androidx.wear.compose.materialcore.PagesState
+import androidx.wear.compose.materialcore.isRoundDevice
 import java.lang.Integer.min
-import kotlin.math.abs
 
 /**
  * A horizontal indicator for a Pager, representing
@@ -288,144 +289,3 @@
         }
     }
 }
-
-/**
- * Represents an internal state of pageIndicator
- */
-internal class PagesState(
-    val totalPages: Int,
-    val pagesOnScreen: Int
-) {
-    // Sizes and alphas of first and last indicators on the screen. Used to show that there're more
-    // pages on the left or on the right, and also for smooth transitions
-    private var firstAlpha = 1f
-    private var lastAlpha = 0f
-    private var firstSize = 1f
-    private var secondSize = 1f
-    private var lastSize = 1f
-    private var lastButOneSize = 1f
-
-    private var smoothProgress = 0f
-
-    // An offset in pages, basically meaning how many pages are hidden to the left.
-    private var hiddenPagesToTheLeft = 0
-
-    // A default size of spacers - invisible items to the left and to the right of
-    // visible indicators, used for smooth transitions
-
-    // Current visible position on the screen.
-    private var visibleDotIndex = 0
-
-    // A size of a left spacer used for smooth transitions
-    val leftSpacerSizeRatio
-        get() = 1 - smoothProgress
-
-    // A size of a right spacer used for smooth transitions
-    val rightSpacerSizeRatio
-        get() = smoothProgress
-
-    /**
-     * Depending on the page index, return an alpha for this indicator
-     *
-     * @param page Page index
-     * @return An alpha of page index- in range 0..1
-     */
-    fun alpha(page: Int): Float =
-        when (page) {
-            0 -> firstAlpha
-            pagesOnScreen -> lastAlpha
-            else -> 1f
-        }
-
-    /**
-     * Depending on the page index, return a size ratio for this indicator
-     *
-     * @param page Page index
-     * @return An size ratio for page index - in range 0..1
-     */
-    fun sizeRatio(page: Int): Float =
-        when (page) {
-            0 -> firstSize
-            1 -> secondSize
-            pagesOnScreen - 1 -> lastButOneSize
-            pagesOnScreen -> lastSize
-            else -> 1f
-        }
-
-    /**
-     * Returns a value in the range 0..1 where 0 is unselected state, and 1 is selected.
-     * Used to show a smooth transition between page indicator items.
-     */
-    fun calculateSelectedRatio(targetPage: Int, offset: Float): Float =
-        (1 - abs(visibleDotIndex + offset - targetPage)).coerceAtLeast(0f)
-
-    // Main function responsible for recalculation of all parameters regarding
-    // to the [selectedPage] and [offset]
-    fun recalculateState(selectedPage: Int, offset: Float) {
-        val pageWithOffset = selectedPage + offset
-
-        // Calculating offsetInPages relating to the [selectedPage].
-
-        // For example, for [selectedPage] = 4 we will see this picture :
-        // O O O O X o. [offsetInPages] will be 0.
-        // But when [selectedPage] will be incremented to 5, it will be seen as
-        // o O O O X o, with [offsetInPages] = 1
-        if (selectedPage > hiddenPagesToTheLeft + pagesOnScreen - 2) {
-            // Set an offset as a difference between current page and pages on the screen,
-            // except if this is not the last page - then offsetInPages is not changed
-            hiddenPagesToTheLeft = (selectedPage - (pagesOnScreen - 2))
-                .coerceAtMost(totalPages - pagesOnScreen)
-        } else if (pageWithOffset <= hiddenPagesToTheLeft) {
-            hiddenPagesToTheLeft = (selectedPage - 1).coerceAtLeast(0)
-        }
-
-        // Condition for scrolling to the right. A smooth scroll to the right is only triggered
-        // when we have more than 2 pages to the right, and currently we're on the right edge.
-        // For example -> o O O O X o -> a small "o" shows that there're more pages to the right
-        val scrolledToTheRight = pageWithOffset > hiddenPagesToTheLeft + pagesOnScreen - 2 &&
-            pageWithOffset < totalPages - 2
-
-        // Condition for scrolling to the left. A smooth scroll to the left is only triggered
-        // when we have more than 2 pages to the left, and currently we're on the left edge.
-        // For example -> o X O O O o -> a small "o" shows that there're more pages to the left
-        val scrolledToTheLeft = pageWithOffset > 1 && pageWithOffset < hiddenPagesToTheLeft + 1
-
-        smoothProgress = if (scrolledToTheLeft || scrolledToTheRight) offset else 0f
-
-        // Calculating exact parameters for border indicators like [firstAlpha], [lastSize], etc.
-        firstAlpha = 1 - smoothProgress
-        lastAlpha = smoothProgress
-        secondSize = 1 - 0.5f * smoothProgress
-
-        // Depending on offsetInPages we'll either show a shrinked first indicator, or full-size
-        firstSize = if (hiddenPagesToTheLeft == 0 ||
-            hiddenPagesToTheLeft == 1 && scrolledToTheLeft
-        ) {
-            1 - smoothProgress
-        } else {
-            0.5f * (1 - smoothProgress)
-        }
-
-        // Depending on offsetInPages and other parameters, we'll either show a shrinked
-        // last indicator, or full-size
-        lastSize =
-            if (hiddenPagesToTheLeft == totalPages - pagesOnScreen - 1 && scrolledToTheRight ||
-                hiddenPagesToTheLeft == totalPages - pagesOnScreen && scrolledToTheLeft
-            ) {
-                smoothProgress
-            } else {
-                0.5f * smoothProgress
-            }
-
-        lastButOneSize = if (scrolledToTheRight || scrolledToTheLeft) {
-            0.5f * (1 + smoothProgress)
-        } else if (hiddenPagesToTheLeft < totalPages - pagesOnScreen) 0.5f else 1f
-
-        // A visibleDot represents a currently selected page on the screen
-        // As we scroll to the left, we add an invisible indicator to the left, shifting all other
-        // indicators to the right. The shift is only possible when a visibleDot = 1,
-        // thus we have to leave it at 1 as we always add a positive offset
-        visibleDotIndex = if (scrolledToTheLeft) 1
-        else selectedPage - hiddenPagesToTheLeft
-    }
-}
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/PositionIndicator.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/PositionIndicator.kt
index 77f29fb..bb7b888 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/PositionIndicator.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/PositionIndicator.kt
@@ -75,6 +75,7 @@
 import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
 import androidx.wear.compose.foundation.lazy.ScalingLazyListItemInfo
 import androidx.wear.compose.foundation.lazy.ScalingLazyListState
+import androidx.wear.compose.materialcore.isRoundDevice
 import kotlin.math.PI
 import kotlin.math.asin
 import kotlin.math.max
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Resources.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Resources.kt
index c34fe83..5569667 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Resources.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Resources.kt
@@ -22,7 +22,6 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 
@@ -45,14 +44,6 @@
     )
 
 @Composable
-internal fun isRoundDevice(): Boolean {
-    val configuration = LocalConfiguration.current
-    return remember(configuration) {
-        configuration.isScreenRound
-    }
-}
-
-@Composable
 internal fun is24HourFormat(): Boolean = DateFormat.is24HourFormat(LocalContext.current)
 
 internal fun currentTimeMillis(): Long = System.currentTimeMillis()
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/TimeText.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/TimeText.kt
index 5dea0bbb..a437877 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/TimeText.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/TimeText.kt
@@ -56,6 +56,7 @@
 import androidx.wear.compose.material.TimeTextDefaults.CurvedTextSeparator
 import androidx.wear.compose.material.TimeTextDefaults.TextSeparator
 import androidx.wear.compose.material.TimeTextDefaults.timeFormat
+import androidx.wear.compose.materialcore.isRoundDevice
 import java.util.Calendar
 import java.util.Locale
 
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/ToggleButton.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/ToggleButton.kt
index 4a208e3..812ac3d 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/ToggleButton.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/ToggleButton.kt
@@ -15,22 +15,28 @@
  */
 package androidx.wear.compose.material
 
+import androidx.compose.foundation.background
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.selection.toggleable
 import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ripple.rememberRipple
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.semantics.role
-import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
 
 /**
@@ -213,24 +219,36 @@
     role: Role = ToggleButtonDefaults.DefaultRole,
     content: @Composable BoxScope.() -> Unit,
 ) {
-    androidx.wear.compose.materialcore.ToggleButton(
-        checked = checked,
-        onCheckedChange = onCheckedChange,
-        modifier = modifier.semantics { this.role = role },
-        enabled = enabled,
-        backgroundColor = { isEnabled, isChecked ->
-            colors.backgroundColor(enabled = isEnabled, checked = isChecked)
-        },
-        border = { _, _ -> null },
-        toggleButtonSize = ToggleButtonDefaults.DefaultToggleButtonSize,
-        interactionSource = interactionSource,
-        shape = shape,
-        content = provideScopeContent(
-            colors.contentColor(enabled = enabled, checked = checked),
-            MaterialTheme.typography.button,
-            content
-        )
-    )
+    Box(
+        contentAlignment = Alignment.Center,
+        modifier = modifier
+            .defaultMinSize(
+                minWidth = ToggleButtonDefaults.DefaultToggleButtonSize,
+                minHeight = ToggleButtonDefaults.DefaultToggleButtonSize
+            )
+            .clip(shape)
+            .toggleable(
+                value = checked,
+                onValueChange = onCheckedChange,
+                enabled = enabled,
+                role = role,
+                interactionSource = interactionSource,
+                indication = rememberRipple()
+            )
+            .background(
+                color = colors.backgroundColor(enabled = enabled, checked = checked).value,
+                shape = shape
+            )
+    ) {
+        val contentColor = colors.contentColor(enabled = enabled, checked = checked).value
+        CompositionLocalProvider(
+            LocalContentColor provides contentColor,
+            LocalContentAlpha provides contentColor.alpha,
+            LocalTextStyle provides MaterialTheme.typography.button,
+        ) {
+            content()
+        }
+    }
 }
 /**
  * Represents the background and content colors used in a toggle button in different states.
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Vignette.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Vignette.kt
index 245a5d0..8350b98 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Vignette.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/Vignette.kt
@@ -24,6 +24,7 @@
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.ContentScale
 import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.materialcore.isRoundDevice
 
 /**
  * Possible combinations for vignette state.
diff --git a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/dialog/Dialog.kt b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/dialog/Dialog.kt
index de976c9..64e3eda 100644
--- a/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/dialog/Dialog.kt
+++ b/wear/compose/compose-material/src/main/java/androidx/wear/compose/material/dialog/Dialog.kt
@@ -51,7 +51,7 @@
 import androidx.wear.compose.material.Scaffold
 import androidx.wear.compose.material.ToggleChip
 import androidx.wear.compose.material.contentColorFor
-import androidx.wear.compose.material.isRoundDevice
+import androidx.wear.compose.materialcore.isRoundDevice
 import kotlinx.coroutines.delay
 
 /**
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index aab9c95..89d4bff 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -204,6 +204,11 @@
   @SuppressCompatibility @kotlin.RequiresOptIn(message="This Wear Material3 API is experimental and is likely to change or to be removed in" + " the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalWearMaterial3Api {
   }
 
+  public final class HorizontalPageIndicatorKt {
+    method @androidx.compose.runtime.Composable public static void HorizontalPageIndicator(androidx.wear.compose.material3.PageIndicatorState pageIndicatorState, optional androidx.compose.ui.Modifier modifier, optional int indicatorStyle, optional long selectedColor, optional long unselectedColor, optional float indicatorSize, optional float spacing);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static androidx.wear.compose.material3.PageIndicatorState rememberPageIndicatorState(int maxPages, kotlin.jvm.functions.Function0<java.lang.Float> selectedPageWithOffset);
+  }
+
   @androidx.compose.runtime.Immutable public final class IconButtonColors {
     ctor public IconButtonColors(long containerColor, long contentColor, long disabledContainerColor, long disabledContentColor);
     method public long getContainerColor();
@@ -256,7 +261,7 @@
     method @androidx.compose.runtime.Composable public static void Icon(androidx.compose.ui.graphics.vector.ImageVector imageVector, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional long tint);
   }
 
-  @androidx.compose.runtime.Immutable public final class InlineSliderColors {
+  @SuppressCompatibility @androidx.compose.runtime.Immutable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderColors {
     ctor public InlineSliderColors(long containerColor, long buttonIconColor, long selectedBarColor, long unselectedBarColor, long barSeparatorColor, long disabledContainerColor, long disabledButtonIconColor, long disabledSelectedBarColor, long disabledUnselectedBarColor, long disabledBarSeparatorColor);
     method public long getBarSeparatorColor();
     method public long getButtonIconColor();
@@ -280,7 +285,7 @@
     property public final long unselectedBarColor;
   }
 
-  public final class InlineSliderDefaults {
+  @SuppressCompatibility @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderDefaults {
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.InlineSliderColors colors(optional long containerColor, optional long buttonIconColor, optional long selectedBarColor, optional long unselectedBarColor, optional long barSeparatorColor, optional long disabledContainerColor, optional long disabledButtonIconColor, optional long disabledSelectedBarColor, optional long disabledUnselectedBarColor, optional long disabledBarSeparatorColor);
     method public androidx.compose.ui.graphics.vector.ImageVector getDecrease();
     method public float getIconSize();
@@ -324,6 +329,29 @@
     method @androidx.compose.runtime.Composable public static void MaterialTheme(optional androidx.wear.compose.material3.ColorScheme colorScheme, optional androidx.wear.compose.material3.Typography typography, optional androidx.wear.compose.material3.Shapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
 
+  public final class PageIndicatorDefaults {
+    method @androidx.compose.runtime.Composable public int style();
+    field public static final androidx.wear.compose.material3.PageIndicatorDefaults INSTANCE;
+  }
+
+  public interface PageIndicatorState {
+    method @IntRange(from=0L) public int getPageCount();
+    method @FloatRange(from=0.0) public kotlin.jvm.functions.Function0<java.lang.Float> getSelectedPageWithOffset();
+    property @IntRange(from=0L) public abstract int pageCount;
+    property @FloatRange(from=0.0) public abstract kotlin.jvm.functions.Function0<java.lang.Float> selectedPageWithOffset;
+  }
+
+  @kotlin.jvm.JvmInline public final value class PageIndicatorStyle {
+    field public static final androidx.wear.compose.material3.PageIndicatorStyle.Companion Companion;
+  }
+
+  public static final class PageIndicatorStyle.Companion {
+    method public int getCurved();
+    method public int getLinear();
+    property public final int Curved;
+    property public final int Linear;
+  }
+
   @androidx.compose.runtime.Immutable public final class RadioButtonColors {
     method public long getDisabledSelectedColor();
     method public long getDisabledUnselectedColor();
@@ -380,11 +408,11 @@
   }
 
   public final class SliderKt {
-    method @androidx.compose.runtime.Composable public static void InlineSlider(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
-    method @androidx.compose.runtime.Composable public static void InlineSlider(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void InlineSlider(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void InlineSlider(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
   }
 
-  public final class StepperDefaults {
+  @SuppressCompatibility @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class StepperDefaults {
     method public androidx.compose.ui.graphics.vector.ImageVector getDecrease();
     method public androidx.compose.ui.graphics.vector.ImageVector getIncrease();
     property public final androidx.compose.ui.graphics.vector.ImageVector Decrease;
@@ -393,8 +421,8 @@
   }
 
   public final class StepperKt {
-    method @androidx.compose.runtime.Composable public static void Stepper(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
-    method @androidx.compose.runtime.Composable public static void Stepper(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void Stepper(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void Stepper(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
   }
 
   public final class SwipeToDismissBoxKt {
@@ -458,7 +486,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors filledTextButtonColors(optional long containerColor, optional long contentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors filledTonalTextButtonColors(optional long containerColor, optional long contentColor);
     method public float getDefaultButtonSize();
-    method public float getExtraSmallButtonSize();
     method public float getLargeButtonSize();
     method public androidx.compose.foundation.shape.RoundedCornerShape getShape();
     method public float getSmallButtonSize();
@@ -466,7 +493,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     property public final float DefaultButtonSize;
-    property public final float ExtraSmallButtonSize;
     property public final float LargeButtonSize;
     property public final float SmallButtonSize;
     property public final androidx.compose.foundation.shape.RoundedCornerShape shape;
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index aab9c95..89d4bff 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -204,6 +204,11 @@
   @SuppressCompatibility @kotlin.RequiresOptIn(message="This Wear Material3 API is experimental and is likely to change or to be removed in" + " the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalWearMaterial3Api {
   }
 
+  public final class HorizontalPageIndicatorKt {
+    method @androidx.compose.runtime.Composable public static void HorizontalPageIndicator(androidx.wear.compose.material3.PageIndicatorState pageIndicatorState, optional androidx.compose.ui.Modifier modifier, optional int indicatorStyle, optional long selectedColor, optional long unselectedColor, optional float indicatorSize, optional float spacing);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static androidx.wear.compose.material3.PageIndicatorState rememberPageIndicatorState(int maxPages, kotlin.jvm.functions.Function0<java.lang.Float> selectedPageWithOffset);
+  }
+
   @androidx.compose.runtime.Immutable public final class IconButtonColors {
     ctor public IconButtonColors(long containerColor, long contentColor, long disabledContainerColor, long disabledContentColor);
     method public long getContainerColor();
@@ -256,7 +261,7 @@
     method @androidx.compose.runtime.Composable public static void Icon(androidx.compose.ui.graphics.vector.ImageVector imageVector, String? contentDescription, optional androidx.compose.ui.Modifier modifier, optional long tint);
   }
 
-  @androidx.compose.runtime.Immutable public final class InlineSliderColors {
+  @SuppressCompatibility @androidx.compose.runtime.Immutable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderColors {
     ctor public InlineSliderColors(long containerColor, long buttonIconColor, long selectedBarColor, long unselectedBarColor, long barSeparatorColor, long disabledContainerColor, long disabledButtonIconColor, long disabledSelectedBarColor, long disabledUnselectedBarColor, long disabledBarSeparatorColor);
     method public long getBarSeparatorColor();
     method public long getButtonIconColor();
@@ -280,7 +285,7 @@
     property public final long unselectedBarColor;
   }
 
-  public final class InlineSliderDefaults {
+  @SuppressCompatibility @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class InlineSliderDefaults {
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.InlineSliderColors colors(optional long containerColor, optional long buttonIconColor, optional long selectedBarColor, optional long unselectedBarColor, optional long barSeparatorColor, optional long disabledContainerColor, optional long disabledButtonIconColor, optional long disabledSelectedBarColor, optional long disabledUnselectedBarColor, optional long disabledBarSeparatorColor);
     method public androidx.compose.ui.graphics.vector.ImageVector getDecrease();
     method public float getIconSize();
@@ -324,6 +329,29 @@
     method @androidx.compose.runtime.Composable public static void MaterialTheme(optional androidx.wear.compose.material3.ColorScheme colorScheme, optional androidx.wear.compose.material3.Typography typography, optional androidx.wear.compose.material3.Shapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
 
+  public final class PageIndicatorDefaults {
+    method @androidx.compose.runtime.Composable public int style();
+    field public static final androidx.wear.compose.material3.PageIndicatorDefaults INSTANCE;
+  }
+
+  public interface PageIndicatorState {
+    method @IntRange(from=0L) public int getPageCount();
+    method @FloatRange(from=0.0) public kotlin.jvm.functions.Function0<java.lang.Float> getSelectedPageWithOffset();
+    property @IntRange(from=0L) public abstract int pageCount;
+    property @FloatRange(from=0.0) public abstract kotlin.jvm.functions.Function0<java.lang.Float> selectedPageWithOffset;
+  }
+
+  @kotlin.jvm.JvmInline public final value class PageIndicatorStyle {
+    field public static final androidx.wear.compose.material3.PageIndicatorStyle.Companion Companion;
+  }
+
+  public static final class PageIndicatorStyle.Companion {
+    method public int getCurved();
+    method public int getLinear();
+    property public final int Curved;
+    property public final int Linear;
+  }
+
   @androidx.compose.runtime.Immutable public final class RadioButtonColors {
     method public long getDisabledSelectedColor();
     method public long getDisabledUnselectedColor();
@@ -380,11 +408,11 @@
   }
 
   public final class SliderKt {
-    method @androidx.compose.runtime.Composable public static void InlineSlider(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
-    method @androidx.compose.runtime.Composable public static void InlineSlider(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void InlineSlider(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void InlineSlider(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean segmented, optional androidx.wear.compose.material3.InlineSliderColors colors);
   }
 
-  public final class StepperDefaults {
+  @SuppressCompatibility @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public final class StepperDefaults {
     method public androidx.compose.ui.graphics.vector.ImageVector getDecrease();
     method public androidx.compose.ui.graphics.vector.ImageVector getIncrease();
     property public final androidx.compose.ui.graphics.vector.ImageVector Decrease;
@@ -393,8 +421,8 @@
   }
 
   public final class StepperKt {
-    method @androidx.compose.runtime.Composable public static void Stepper(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
-    method @androidx.compose.runtime.Composable public static void Stepper(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void Stepper(float value, kotlin.jvm.functions.Function1<? super java.lang.Float,kotlin.Unit> onValueChange, int steps, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.ranges.ClosedFloatingPointRange<java.lang.Float> valueRange, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.runtime.Composable @androidx.wear.compose.material3.ExperimentalWearMaterial3Api public static void Stepper(int value, kotlin.jvm.functions.Function1<? super java.lang.Integer,kotlin.Unit> onValueChange, kotlin.ranges.IntProgression valueProgression, kotlin.jvm.functions.Function0<kotlin.Unit> decreaseIcon, kotlin.jvm.functions.Function0<kotlin.Unit> increaseIcon, optional androidx.compose.ui.Modifier modifier, optional long backgroundColor, optional long contentColor, optional long iconColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> content);
   }
 
   public final class SwipeToDismissBoxKt {
@@ -458,7 +486,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors filledTextButtonColors(optional long containerColor, optional long contentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors filledTonalTextButtonColors(optional long containerColor, optional long contentColor);
     method public float getDefaultButtonSize();
-    method public float getExtraSmallButtonSize();
     method public float getLargeButtonSize();
     method public androidx.compose.foundation.shape.RoundedCornerShape getShape();
     method public float getSmallButtonSize();
@@ -466,7 +493,6 @@
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.TextButtonColors textButtonColors(optional long containerColor, optional long contentColor, optional long disabledContainerColor, optional long disabledContentColor);
     method @androidx.compose.runtime.Composable public androidx.wear.compose.material3.ToggleButtonColors textToggleButtonColors(optional long checkedContainerColor, optional long checkedContentColor, optional long uncheckedContainerColor, optional long uncheckedContentColor, optional long disabledCheckedContainerColor, optional long disabledCheckedContentColor, optional long disabledUncheckedContainerColor, optional long disabledUncheckedContentColor);
     property public final float DefaultButtonSize;
-    property public final float ExtraSmallButtonSize;
     property public final float LargeButtonSize;
     property public final float SmallButtonSize;
     property public final androidx.compose.foundation.shape.RoundedCornerShape shape;
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SliderDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SliderDemo.kt
index b17cb00..07434b1 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SliderDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SliderDemo.kt
@@ -38,6 +38,7 @@
 import androidx.wear.compose.integration.demos.common.Centralize
 import androidx.wear.compose.integration.demos.common.ComposableDemo
 import androidx.wear.compose.integration.demos.common.DemoCategory
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.InlineSlider
 import androidx.wear.compose.material3.InlineSliderColors
@@ -83,6 +84,7 @@
         )
     )
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderDemo(segmented: Boolean = false) {
     var enabledValue by remember { mutableFloatStateOf(5f) }
@@ -126,6 +128,7 @@
     }
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderWithIntegersDemo() {
     var valueWithoutSegments by remember { mutableIntStateOf(5) }
@@ -171,6 +174,7 @@
     }
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderCustomColorsDemo() {
     var value by remember { mutableFloatStateOf(4.5f) }
@@ -202,6 +206,7 @@
     }
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun DefaultInlineSlider(
     value: Float,
@@ -230,6 +235,7 @@
     )
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun DefaultInlineSlider(
     value: Int,
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextButtonDemo.kt
index de378c5..4f814a3 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextButtonDemo.kt
@@ -138,13 +138,6 @@
                 TextButtonWithSize(TextButtonDefaults.SmallButtonSize)
             }
         }
-        item {
-            Row(verticalAlignment = Alignment.CenterVertically) {
-                Text("${TextButtonDefaults.ExtraSmallButtonSize.value.toInt()}dp")
-                Spacer(Modifier.width(4.dp))
-                TextButtonWithSize(TextButtonDefaults.ExtraSmallButtonSize)
-            }
-        }
     }
 }
 
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextToggleButtonDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextToggleButtonDemo.kt
index c1506db..0025ac5 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextToggleButtonDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/TextToggleButtonDemo.kt
@@ -96,17 +96,6 @@
                 )
             }
         }
-        item {
-            Row(verticalAlignment = Alignment.CenterVertically) {
-                Text("${TextButtonDefaults.ExtraSmallButtonSize.value.toInt()}dp")
-                Spacer(Modifier.width(4.dp))
-                TextToggleButtonsDemo(
-                    enabled = true,
-                    checked = true,
-                    size = TextButtonDefaults.ExtraSmallButtonSize
-                )
-            }
-        }
     }
 }
 
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
index 432f227..01c97d0 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
@@ -21,6 +21,7 @@
 import androidx.wear.compose.integration.demos.common.DemoCategory
 import androidx.wear.compose.material3.samples.EdgeSwipeForSwipeToDismiss
 import androidx.wear.compose.material3.samples.FixedFontSize
+import androidx.wear.compose.material3.samples.HorizontalPageIndicatorSample
 import androidx.wear.compose.material3.samples.SimpleSwipeToDismissBox
 import androidx.wear.compose.material3.samples.StatefulSwipeToDismissBox
 import androidx.wear.compose.material3.samples.StepperSample
@@ -117,5 +118,8 @@
                 ComposableDemo("Edge swipe") { EdgeSwipeForSwipeToDismiss(it.navigateBack) },
             )
         ),
+        ComposableDemo("HorizontalPageIndicator") {
+            Centralize { HorizontalPageIndicatorSample() }
+        },
     )
 )
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/HorizontalPageIndicatorSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/HorizontalPageIndicatorSample.kt
new file mode 100644
index 0000000..5f1e287
--- /dev/null
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/HorizontalPageIndicatorSample.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.compose.material3.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+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.unit.dp
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
+import androidx.wear.compose.material3.HorizontalPageIndicator
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.InlineSlider
+import androidx.wear.compose.material3.InlineSliderDefaults
+import androidx.wear.compose.material3.PageIndicatorState
+import androidx.wear.compose.material3.rememberPageIndicatorState
+
+@OptIn(ExperimentalWearMaterial3Api::class)
+@Sampled
+@Composable
+fun HorizontalPageIndicatorSample() {
+    val maxPages = 9
+    var selectedPage by remember { mutableStateOf(0) }
+    var finalValue by remember { mutableStateOf(0) }
+
+    val animatedSelectedPage by animateFloatAsState(
+        targetValue = selectedPage.toFloat(), label = "animateSelectedPage",
+    ) {
+        finalValue = it.toInt()
+    }
+
+    val pageIndicatorState: PageIndicatorState =
+        rememberPageIndicatorState(maxPages) { animatedSelectedPage }
+
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(6.dp)
+    ) {
+        InlineSlider(
+            modifier = Modifier.align(Alignment.Center),
+            value = selectedPage,
+            increaseIcon = { Icon(InlineSliderDefaults.Increase, "Increase") },
+            decreaseIcon = { Icon(InlineSliderDefaults.Decrease, "Decrease") },
+            valueProgression = 0 until maxPages,
+            onValueChange = { selectedPage = it }
+        )
+        HorizontalPageIndicator(
+            pageIndicatorState = pageIndicatorState
+        )
+    }
+}
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SliderSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SliderSample.kt
index 60e1d59..82eeb6e 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SliderSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SliderSample.kt
@@ -22,11 +22,13 @@
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.InlineSlider
 import androidx.wear.compose.material3.InlineSliderDefaults
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderSample() {
     var value by remember { mutableStateOf(4.5f) }
@@ -42,6 +44,7 @@
 }
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderSegmentedSample() {
     var value by remember { mutableStateOf(2f) }
@@ -57,6 +60,7 @@
 }
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun InlineSliderWithIntegerSample() {
     var value by remember { mutableStateOf(4) }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/StepperSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/StepperSample.kt
index 0ca665d..a0f0f9c 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/StepperSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/StepperSample.kt
@@ -23,6 +23,7 @@
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.Stepper
 import androidx.wear.compose.material3.StepperDefaults
@@ -30,6 +31,7 @@
 import androidx.wear.compose.material3.rangeSemantics
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun StepperSample() {
     var value by remember { mutableStateOf(2f) }
@@ -44,6 +46,7 @@
 }
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun StepperWithIntegerSample() {
     var value by remember { mutableStateOf(2) }
@@ -57,6 +60,7 @@
 }
 
 @Sampled
+@OptIn(ExperimentalWearMaterial3Api::class)
 @Composable
 fun StepperWithRangeSemanticsSample() {
     var value by remember { mutableStateOf(2f) }
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorScreenshotTest.kt
new file mode 100644
index 0000000..306ca072
--- /dev/null
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorScreenshotTest.kt
@@ -0,0 +1,136 @@
+/*
+ * 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.compose.material3
+
+import android.os.Build
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.wear.compose.material3.HorizontalPageIndicatorTest.Companion.pageIndicatorState
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestName
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+class HorizontalPageIndicatorScreenshotTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    @get:Rule
+    val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
+
+    @get:Rule
+    val testName = TestName()
+
+    @Test
+    fun horizontalPageIndicator_circular_selected_page() {
+        selected_page(PageIndicatorStyle.Curved, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontalPageIndicator_linear_selected_page() {
+        selected_page(PageIndicatorStyle.Linear, LayoutDirection.Ltr)
+    }
+
+    @Test
+    fun horizontalPageIndicator_circular_selected_page_rtl() {
+        selected_page(PageIndicatorStyle.Curved, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun horizontalPageIndicator_linear_selected_page_rtl() {
+        selected_page(PageIndicatorStyle.Linear, LayoutDirection.Rtl)
+    }
+
+    @Test
+    fun horizontalPageIndicator_circular_between_pages() {
+        between_pages(PageIndicatorStyle.Curved)
+    }
+
+    @Test
+    fun horizontalPageIndicator_linear_between_pages() {
+        between_pages(PageIndicatorStyle.Linear)
+    }
+
+    private fun selected_page(
+        indicatorStyle: PageIndicatorStyle,
+        layoutDirection: LayoutDirection
+    ) {
+        rule.setContentWithTheme {
+            CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+                defaultHorizontalPageIndicator(indicatorStyle)
+            }
+        }
+        rule.waitForIdle()
+
+        rule.onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, testName.methodName)
+    }
+
+    private fun between_pages(indicatorStyle: PageIndicatorStyle) {
+        rule.setContentWithTheme {
+            HorizontalPageIndicator(
+                modifier = Modifier
+                    .testTag(TEST_TAG)
+                    .size(200.dp),
+                indicatorStyle = indicatorStyle,
+                pageIndicatorState = pageIndicatorState(0.5f),
+                selectedColor = Color.Yellow,
+                unselectedColor = Color.Red,
+                indicatorSize = 15.dp
+            )
+        }
+        rule.waitForIdle()
+
+        rule.onNodeWithTag(TEST_TAG)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, testName.methodName)
+    }
+
+    @Composable
+    private fun defaultHorizontalPageIndicator(indicatorStyle: PageIndicatorStyle) {
+        HorizontalPageIndicator(
+            modifier = Modifier
+                .testTag(TEST_TAG)
+                .size(200.dp),
+            indicatorStyle = indicatorStyle,
+            pageIndicatorState = pageIndicatorState(),
+            selectedColor = Color.Yellow,
+            unselectedColor = Color.Red,
+            indicatorSize = 15.dp
+        )
+    }
+}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorTest.kt
new file mode 100644
index 0000000..7062af17
--- /dev/null
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/HorizontalPageIndicatorTest.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.wear.compose.material3
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import org.junit.Rule
+import org.junit.Test
+
+@RequiresApi(Build.VERSION_CODES.O)
+class HorizontalPageIndicatorTest {
+    @get:Rule
+    val rule = createComposeRule()
+
+    @Test
+    public fun supports_testtag_circular() {
+        rule.setContentWithTheme {
+            HorizontalPageIndicator(
+                modifier = Modifier.testTag(TEST_TAG),
+                pageIndicatorState = pageIndicatorState(),
+                indicatorStyle = PageIndicatorStyle.Curved
+            )
+        }
+        rule.onNodeWithTag(TEST_TAG).assertExists()
+    }
+
+    @Test
+    public fun supports_testtag_linear() {
+        rule.setContentWithTheme {
+            HorizontalPageIndicator(
+                modifier = Modifier.testTag(TEST_TAG),
+                pageIndicatorState = pageIndicatorState(),
+                indicatorStyle = PageIndicatorStyle.Linear
+            )
+        }
+        rule.onNodeWithTag(TEST_TAG).assertExists()
+    }
+
+    @Test
+    public fun position_is_selected_circular() {
+        position_is_selected(PageIndicatorStyle.Curved)
+    }
+
+    @Test
+    public fun position_is_selected_linear() {
+        position_is_selected(PageIndicatorStyle.Linear)
+    }
+
+    @Test
+    public fun in_between_positions_circular() {
+        in_between_positions(PageIndicatorStyle.Curved)
+    }
+
+    @Test
+    public fun in_between_positions_linear() {
+        in_between_positions(PageIndicatorStyle.Linear)
+    }
+
+    private fun position_is_selected(indicatorStyle: PageIndicatorStyle) {
+        rule.setContentWithTheme {
+            HorizontalPageIndicator(
+                modifier = Modifier
+                    .testTag(TEST_TAG)
+                    .size(150.dp),
+                indicatorStyle = indicatorStyle,
+                pageIndicatorState = pageIndicatorState(),
+                selectedColor = selectedColor,
+                unselectedColor = unselectedColor,
+                indicatorSize = 20.dp
+            )
+        }
+        rule.waitForIdle()
+
+        // A selected dot with specified color should be visible on the screen, which is apprx 1.3%
+        // (1.3% per dot, 1 dot in total)
+        rule.onNodeWithTag(TEST_TAG).captureToImage()
+            .assertColorInPercentageRange(selectedColor, 1.2f..1.5f)
+        // Unselected dots should also be visible on the screen, and should take around 4%
+        // (1.3% per dot, 3 dots total)
+        rule.onNodeWithTag(TEST_TAG).captureToImage()
+            .assertColorInPercentageRange(unselectedColor, 3.8f..4.5f)
+    }
+
+    private fun in_between_positions(indicatorStyle: PageIndicatorStyle) {
+        rule.setContentWithTheme {
+            HorizontalPageIndicator(
+                modifier = Modifier
+                    .testTag(TEST_TAG)
+                    .size(150.dp)
+                    .fillMaxWidth(),
+                pageIndicatorState = pageIndicatorState(pageOffset = 0.5f),
+                indicatorStyle = indicatorStyle,
+                selectedColor = selectedColor,
+                unselectedColor = unselectedColor,
+                indicatorSize = 20.dp
+            )
+        }
+        rule.waitForIdle()
+
+        // Selected color should occupy 2 dots with space in between, which
+        // approximately equals to 3.5%
+        rule.onNodeWithTag(TEST_TAG).captureToImage()
+            .assertColorInPercentageRange(selectedColor, 3f..4f)
+        // Unselected dots ( which doesn't participate in color merge)
+        // should also be visible on the screen, and should take around 2.7%
+        // (1.3% per dot, 2 dots in total)
+        rule.onNodeWithTag(TEST_TAG).captureToImage()
+            .assertColorInPercentageRange(unselectedColor, 2.5f..3f)
+    }
+
+    companion object {
+        val selectedColor = Color.Yellow
+        val unselectedColor = Color.Red
+
+        fun pageIndicatorState(
+            pageOffset: Float = 0f,
+            selectedPage: Int = 1,
+            pageCount: Int = 4
+        ) = object : PageIndicatorState {
+            override val selectedPageWithOffset: () -> Float
+                get() = { selectedPage + pageOffset }
+            override val pageCount: Int
+                get() = pageCount
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderScreenshotTest.kt
index bd874d2..bff1c22 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderScreenshotTest.kt
@@ -38,6 +38,7 @@
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.InlineSlider
 import androidx.wear.compose.material3.InlineSliderDefaults
@@ -53,6 +54,7 @@
 @MediumTest
 @RunWith(AndroidJUnit4::class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@OptIn(ExperimentalWearMaterial3Api::class)
 class SliderScreenshotTest {
 
     @get:Rule
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderTest.kt
index 454127a..d14aee4 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SliderTest.kt
@@ -42,6 +42,7 @@
 import org.junit.Rule
 import org.junit.Test
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 public class SliderTest {
     @get:Rule
     public val rule = createComposeRule()
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperScreenshotTest.kt
index 0a66e9b..292f290 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperScreenshotTest.kt
@@ -38,6 +38,7 @@
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.wear.compose.material3.ExperimentalWearMaterial3Api
 import androidx.wear.compose.material3.FilledTonalButton
 import androidx.wear.compose.material3.Icon
 import androidx.wear.compose.material3.MaterialTheme
@@ -55,6 +56,7 @@
 @MediumTest
 @RunWith(AndroidJUnit4::class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@OptIn(ExperimentalWearMaterial3Api::class)
 public class StepperScreenshotTest {
     @get:Rule
     public val rule = createComposeRule()
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperTest.kt
index c683c09..81db021 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/StepperTest.kt
@@ -49,6 +49,7 @@
 import org.junit.Rule
 import org.junit.Test
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 public class StepperTest {
     @get:Rule
     public val rule = createComposeRule()
@@ -451,6 +452,7 @@
     private val DefaultIconHeight = 24.dp
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 public class IntegerStepperTest {
     @get:Rule
     public val rule = createComposeRule()
@@ -596,6 +598,7 @@
     assertEquals(newValue, state.value)
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 private fun ComposeContentTestRule.initDefaultStepper(
     state: MutableState<Float>,
     valueRange: ClosedFloatingPointRange<Float>,
@@ -644,6 +647,7 @@
     assertEquals(newValue, state.value)
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 private fun ComposeContentTestRule.initDefaultStepper(
     state: MutableState<Int>,
     valueProgression: IntProgression,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonScreenshotTest.kt
index 11b0b55..a516d31 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonScreenshotTest.kt
@@ -39,7 +39,6 @@
 import androidx.wear.compose.material3.TextButton
 import androidx.wear.compose.material3.TextButtonDefaults
 import androidx.wear.compose.material3.setContentWithTheme
-import androidx.wear.compose.material3.touchTargetAwareSize
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
@@ -61,157 +60,91 @@
 
     @Test
     fun filled_text_button_enabled() = verifyScreenshot {
-        sampleFilledTextButton(enabled = true, isCompact = false)
+        sampleFilledTextButton(enabled = true)
     }
 
     @Test
     fun filled_text_button_disabled() =
         verifyScreenshot {
-            sampleFilledTextButton(enabled = false, isCompact = false)
+            sampleFilledTextButton(enabled = false)
         }
 
     @Test
     fun filled_tonal_text_button_enabled() = verifyScreenshot {
-        sampleFilledTonalTextButton(enabled = true, isCompact = false)
+        sampleFilledTonalTextButton(enabled = true)
     }
 
     @Test
     fun filled_tonal_text_button_disabled() =
         verifyScreenshot {
-            sampleFilledTonalTextButton(enabled = false, isCompact = false)
+            sampleFilledTonalTextButton(enabled = false)
         }
 
     @Test
     fun outlined_text_button_enabled() = verifyScreenshot {
-        sampleOutlinedTextButton(enabled = true, isCompact = false)
+        sampleOutlinedTextButton(enabled = true)
     }
 
     @Test
     fun outlined_text_button_disabled() = verifyScreenshot {
-        sampleOutlinedTextButton(enabled = false, isCompact = false)
+        sampleOutlinedTextButton(enabled = false)
     }
 
     @Test
     fun text_button_enabled() = verifyScreenshot {
-        sampleTextButton(enabled = true, isCompact = false)
+        sampleTextButton(enabled = true)
     }
 
     @Test
     fun text_button_disabled() = verifyScreenshot {
-        sampleTextButton(enabled = false, isCompact = false)
-    }
-
-    @Test
-    fun filled_compact_text_button_enabled() = verifyScreenshot {
-        sampleFilledTextButton(enabled = true, isCompact = true)
-    }
-
-    @Test
-    fun filled_compact_text_button_disabled() =
-        verifyScreenshot {
-            sampleFilledTextButton(enabled = false, isCompact = true)
-        }
-
-    @Test
-    fun filled_tonal_compact_text_button_enabled() = verifyScreenshot {
-        sampleFilledTonalTextButton(enabled = true, isCompact = true)
-    }
-
-    @Test
-    fun filled_tonal_compact_text_button_disabled() =
-        verifyScreenshot {
-            sampleFilledTonalTextButton(enabled = false, isCompact = true)
-        }
-
-    @Test
-    fun outlined_compact_text_button_enabled() = verifyScreenshot {
-        sampleOutlinedTextButton(enabled = true, isCompact = true)
-    }
-
-    @Test
-    fun outlined_compact_text_button_disabled() = verifyScreenshot {
-        sampleOutlinedTextButton(enabled = false, isCompact = true)
-    }
-
-    @Test
-    fun compact_text_button_enabled() = verifyScreenshot {
-        sampleTextButton(enabled = true, isCompact = true)
-    }
-
-    @Test
-    fun compact_text_button_disabled() = verifyScreenshot {
-        sampleTextButton(enabled = false, isCompact = true)
+        sampleTextButton(enabled = false)
     }
 
     @Composable
-    private fun sampleFilledTextButton(enabled: Boolean, isCompact: Boolean) {
+    private fun sampleFilledTextButton(enabled: Boolean) {
         TextButton(
             onClick = {},
             colors = TextButtonDefaults.filledTextButtonColors(),
             enabled = enabled,
-            modifier = Modifier
-                .testTag(TEST_TAG)
-                .then(
-                    if (isCompact)
-                        Modifier.touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-                    else Modifier
-                )
+            modifier = Modifier.testTag(TEST_TAG)
         ) {
-            Text(text = if (isCompact) "TB" else "ABC")
+            Text(text = "ABC")
         }
     }
 
     @Composable
-    private fun sampleFilledTonalTextButton(enabled: Boolean, isCompact: Boolean) {
+    private fun sampleFilledTonalTextButton(enabled: Boolean) {
         TextButton(
             onClick = {},
             colors = TextButtonDefaults.filledTonalTextButtonColors(),
             enabled = enabled,
-            modifier = Modifier
-                .testTag(TEST_TAG)
-                .then(
-                    if (isCompact)
-                        Modifier.touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-                    else Modifier
-                )
+            modifier = Modifier.testTag(TEST_TAG)
         ) {
-            Text(text = if (isCompact) "TB" else "ABC")
+            Text(text = "ABC")
         }
     }
 
     @Composable
-    private fun sampleOutlinedTextButton(enabled: Boolean, isCompact: Boolean) {
+    private fun sampleOutlinedTextButton(enabled: Boolean) {
         TextButton(
             onClick = {},
             colors = TextButtonDefaults.outlinedTextButtonColors(),
             border = ButtonDefaults.outlinedButtonBorder(enabled),
             enabled = enabled,
-            modifier = Modifier
-                .testTag(TEST_TAG)
-                .then(
-                    if (isCompact)
-                        Modifier.touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-                    else Modifier
-                )
+            modifier = Modifier.testTag(TEST_TAG)
         ) {
-            Text(text = if (isCompact) "O" else "ABC")
+            Text(text = "ABC")
         }
     }
 
     @Composable
-    private fun sampleTextButton(enabled: Boolean, isCompact: Boolean) {
+    private fun sampleTextButton(enabled: Boolean) {
         TextButton(
             onClick = {},
             enabled = enabled,
-            modifier = Modifier
-                .testTag(TEST_TAG)
-                .then(
-                    if (isCompact)
-                        Modifier.touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-                    else Modifier
-                )
+            modifier = Modifier.testTag(TEST_TAG)
         ) {
-            Text(text = if (isCompact) "TB" else "ABC")
+            Text(text = "ABC")
         }
     }
 
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
index 99f2e27..4ffc5a7 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextButtonTest.kt
@@ -48,7 +48,6 @@
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.dp
 import androidx.wear.compose.material3.TextButtonDefaults.DefaultButtonSize
-import androidx.wear.compose.material3.TextButtonDefaults.ExtraSmallButtonSize
 import androidx.wear.compose.material3.TextButtonDefaults.LargeButtonSize
 import androidx.wear.compose.material3.TextButtonDefaults.SmallButtonSize
 import org.junit.Assert.assertEquals
@@ -297,20 +296,6 @@
         }
     }
 
-    @Test
-    fun gives_extra_small_button_correct_tap_size() {
-        rule.verifyTapSize(
-            expectedSize = MinimumButtonTapSize
-        ) { modifier ->
-            TextButton(
-                onClick = {},
-                modifier = modifier.touchTargetAwareSize(ExtraSmallButtonSize)
-            ) {
-                Text("xs")
-            }
-        }
-    }
-
     @RequiresApi(Build.VERSION_CODES.O)
     @Test
     fun default_shape_is_circular() {
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
index d4dd6db..59ca327 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/TextToggleButtonTest.kt
@@ -331,20 +331,6 @@
         }
 
     @Test
-    fun gives_extraSmall_correct_tapSize() =
-        rule.verifyTapSize(48.dp) {
-            TextToggleButton(
-                enabled = true,
-                checked = true,
-                onCheckedChange = { },
-                content = { },
-                modifier = Modifier
-                    .testTag(TEST_TAG)
-                    .touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-            )
-        }
-
-    @Test
     fun gives_large_correct_tapSize() =
         rule.verifyTapSize(60.dp) {
             TextToggleButton(
@@ -387,20 +373,6 @@
         }
 
     @Test
-    fun gives_extraSmall_correct_size() =
-        rule.verifyActualSize(48.dp) {
-            TextToggleButton(
-                enabled = true,
-                checked = true,
-                onCheckedChange = { },
-                content = { },
-                modifier = Modifier
-                    .testTag(TEST_TAG)
-                    .touchTargetAwareSize(TextButtonDefaults.ExtraSmallButtonSize)
-            )
-        }
-
-    @Test
     fun gives_large_correct_size() =
         rule.verifyActualSize(60.dp) {
             TextToggleButton(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/HorizontalPageIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/HorizontalPageIndicator.kt
new file mode 100644
index 0000000..80a8572
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/HorizontalPageIndicator.kt
@@ -0,0 +1,471 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3
+
+import androidx.annotation.FloatRange
+import androidx.annotation.IntRange
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.CurvedAlignment
+import androidx.wear.compose.foundation.CurvedDirection
+import androidx.wear.compose.foundation.CurvedLayout
+import androidx.wear.compose.foundation.CurvedModifier
+import androidx.wear.compose.foundation.CurvedScope
+import androidx.wear.compose.foundation.angularSizeDp
+import androidx.wear.compose.foundation.background
+import androidx.wear.compose.foundation.curvedBox
+import androidx.wear.compose.foundation.curvedRow
+import androidx.wear.compose.foundation.radialSize
+import androidx.wear.compose.foundation.size
+import androidx.wear.compose.foundation.weight
+import androidx.wear.compose.material3.PageIndicatorDefaults.MaxNumberOfIndicators
+import androidx.wear.compose.materialcore.PagesState
+import androidx.wear.compose.materialcore.isLayoutDirectionRtl
+import androidx.wear.compose.materialcore.isRoundDevice
+
+/**
+ * Horizontal page indicator for use with [HorizontalPager], representing
+ * the currently active page and the total number of pages.
+ * Pages are indicated as a Circle shape.
+ * The indicator shows up to six pages individually -
+ * if there are more than six pages, [HorizontalPageIndicator] shows a
+ * half-size indicator to the left or right to indicate that more are available.
+ *
+ * Here's how different positions 0..10 might be visually represented:
+ * "X" is selected item, "O" and "o" full and half size items respectively.
+ *
+ * O X O O O o - 2nd position out of 10. There are no more items on the left but more on the right
+ * o O O O X o - current page could be 6, 7 or 8 out of 10, as there are more possible items
+ * on the left and on the right
+ * o O O O X O - current page is 9 out of 10, as there're no more items on the right
+ *
+ * [HorizontalPageIndicator] may be linear or curved, depending on [indicatorStyle]. By default
+ * it depends on the screen shape of the device - for circular screens it will be curved,
+ * whilst for square screens it will be linear.
+ *
+ * @sample androidx.wear.compose.material3.samples.HorizontalPageIndicatorSample
+ *
+ * @param pageIndicatorState The state object of a [HorizontalPageIndicator] to be used to
+ * observe the Pager's state.
+ * @param modifier Modifier to be applied to the [HorizontalPageIndicator]
+ * @param indicatorStyle The style of [HorizontalPageIndicator] - may be linear or curved.
+ * By default determined by the screen shape.
+ * @param selectedColor The color of the selected [HorizontalPageIndicator] item
+ * @param unselectedColor The color of unselected [HorizontalPageIndicator] items.
+ * Defaults to [selectedColor] with 30% alpha
+ * @param indicatorSize The size of each [HorizontalPageIndicator] item in [Dp]
+ * @param spacing The spacing between indicator items in [Dp]
+ **/
+@Composable
+public fun HorizontalPageIndicator(
+    pageIndicatorState: PageIndicatorState,
+    modifier: Modifier = Modifier,
+    indicatorStyle: PageIndicatorStyle = PageIndicatorDefaults.style(),
+    selectedColor: Color = MaterialTheme.colorScheme.onBackground,
+    unselectedColor: Color = selectedColor.copy(alpha = 0.3f),
+    indicatorSize: Dp = 6.dp,
+    spacing: Dp = 4.dp
+) {
+    val selectedPage: Int = pageIndicatorState.selectedPageWithOffset().toInt()
+    val offset = pageIndicatorState.selectedPageWithOffset() - selectedPage
+
+    val pagesOnScreen = Integer.min(MaxNumberOfIndicators, pageIndicatorState.pageCount)
+    val pagesState = remember(pageIndicatorState.pageCount) {
+        PagesState(
+            totalPages = pageIndicatorState.pageCount,
+            pagesOnScreen = pagesOnScreen
+        )
+    }
+    pagesState.recalculateState(selectedPage, offset)
+
+    val leftSpacerSize = (indicatorSize + spacing) * pagesState.leftSpacerSizeRatio
+    val rightSpacerSize = (indicatorSize + spacing) * pagesState.rightSpacerSizeRatio
+
+    when (indicatorStyle) {
+        PageIndicatorStyle.Linear -> {
+            LinearPageIndicator(
+                modifier = modifier,
+                visibleDotIndex = pagesState.visibleDotIndex,
+                pagesOnScreen = pagesOnScreen,
+                indicator = { page ->
+                    LinearIndicator(
+                        page = page,
+                        pagesState = pagesState,
+                        unselectedColor = unselectedColor,
+                        indicatorSize = indicatorSize,
+                        spacing = spacing,
+                    )
+                },
+                selectedIndicator = {
+                    LinearSelectedIndicator(
+                        indicatorSize = indicatorSize,
+                        spacing = spacing,
+                        selectedColor = selectedColor,
+                        progress = offset
+                    )
+                },
+                spacerLeft = { LinearSpacer(leftSpacerSize) },
+                spacerRight = { LinearSpacer(rightSpacerSize) }
+            )
+        }
+
+        PageIndicatorStyle.Curved -> {
+            CurvedPageIndicator(
+                modifier = modifier,
+                visibleDotIndex = pagesState.visibleDotIndex,
+                pagesOnScreen = pagesOnScreen,
+                indicator = { page ->
+                    curvedIndicator(
+                        page = page,
+                        size = indicatorSize,
+                        unselectedColor = unselectedColor,
+                        pagesState = pagesState
+                    )
+                },
+                itemsSpacer = { curvedSpacer(indicatorSize + spacing) },
+                selectedIndicator = {
+                    curvedSelectedIndicator(
+                        indicatorSize = indicatorSize,
+                        spacing = spacing,
+                        selectedColor = selectedColor,
+                        progress = offset
+                    )
+                },
+                spacerLeft = { curvedSpacer(leftSpacerSize) },
+                spacerRight = { curvedSpacer(rightSpacerSize) }
+            )
+        }
+    }
+}
+
+/**
+ * The style of [HorizontalPageIndicator]. May be Curved or Linear
+ */
+@kotlin.jvm.JvmInline
+public value class PageIndicatorStyle internal constructor(internal val value: Int) {
+    companion object {
+        /**
+         * Curved style of [HorizontalPageIndicator]
+         */
+        public val Curved = PageIndicatorStyle(0)
+
+        /**
+         * Linear style of [HorizontalPageIndicator]
+         */
+        public val Linear = PageIndicatorStyle(1)
+    }
+}
+
+/**
+ * Contains the default values used by [HorizontalPageIndicator]
+ */
+public object PageIndicatorDefaults {
+
+    /**
+     * Default style of [HorizontalPageIndicator]. Depending on shape of device,
+     * it returns either Curved or Linear style.
+     */
+    @Composable
+    public fun style(): PageIndicatorStyle =
+        if (isRoundDevice()) PageIndicatorStyle.Curved
+        else PageIndicatorStyle.Linear
+
+    internal val MaxNumberOfIndicators = 6
+}
+
+// TODO(b/290732498): Add rememberPageIndicatorState for HorizontalPager
+//  once HorizontalPager is stable
+
+/**
+ * Creates and remembers [PageIndicatorState] based on [maxPages] and [selectedPageWithOffset]
+ * parameters.
+ */
+@ExperimentalWearMaterial3Api
+@Composable
+public fun rememberPageIndicatorState(
+    maxPages: Int,
+    @Suppress("PrimitiveInLambda")
+    selectedPageWithOffset: () -> Float
+): PageIndicatorState =
+    remember(maxPages, selectedPageWithOffset) {
+        object : PageIndicatorState {
+
+            override val selectedPageWithOffset: () -> Float
+                get() = selectedPageWithOffset
+
+            override val pageCount: Int
+                get() = maxPages
+        }
+    }
+
+/**
+ * An interface for connection between Pager and [HorizontalPageIndicator].
+ */
+public interface PageIndicatorState {
+    /**
+     * The currently selected page index with offset.
+     * Integer part represents the selected page index and the fractional part represents
+     * the offset as a fraction of the transition from the selected page
+     * to the next page in the range 0f..1f
+     *
+     * For example 5.5f equals to selectedPage = 5, offset 0.5f
+     *
+     * Changes when a scroll (drag, swipe or fling) between pages happens in Pager.
+     */
+    @Suppress("PrimitiveInLambda")
+    @get:FloatRange(from = 0.0)
+    public val selectedPageWithOffset: () -> Float
+
+    /**
+     * Total number of pages
+     */
+    @get:IntRange(from = 0)
+    public val pageCount: Int
+}
+
+@Composable
+private fun LinearPageIndicator(
+    modifier: Modifier,
+    visibleDotIndex: Int,
+    pagesOnScreen: Int,
+    @Suppress("PrimitiveInLambda")
+    indicator: @Composable (Int) -> Unit,
+    selectedIndicator: @Composable () -> Unit,
+    spacerLeft: @Composable () -> Unit,
+    spacerRight: @Composable () -> Unit
+) {
+    Row(
+        modifier = modifier.fillMaxSize(),
+        horizontalArrangement = Arrangement.Center,
+        verticalAlignment = Alignment.Bottom
+    ) {
+        // drawing 1 extra spacer for transition
+        spacerLeft()
+        for (page in 0 until visibleDotIndex) {
+            indicator(page)
+        }
+        Box(contentAlignment = Alignment.Center) {
+            Row(verticalAlignment = Alignment.Bottom) {
+                indicator(visibleDotIndex)
+                indicator(visibleDotIndex + 1)
+            }
+            Box {
+                selectedIndicator()
+            }
+        }
+        for (page in visibleDotIndex + 2..pagesOnScreen) {
+            indicator(page)
+        }
+        spacerRight()
+    }
+}
+
+@Composable
+private fun LinearSelectedIndicator(
+    indicatorSize: Dp,
+    spacing: Dp,
+    selectedColor: Color,
+    progress: Float
+) {
+    val horizontalPadding = spacing / 2
+    val isRtl = isLayoutDirectionRtl()
+    Spacer(
+        modifier = Modifier
+            .drawWithCache {
+                // Adding 2px to fully cover edges of non-selected indicators
+                val strokeWidth = indicatorSize.toPx() + 2
+                val startX = horizontalPadding.toPx() + strokeWidth / 2
+                val endX = this.size.width - horizontalPadding.toPx() - strokeWidth / 2
+                val drawWidth = endX - startX
+
+                val startSpacerWeight = (progress * 2 - 1).coerceAtLeast(0f)
+                val endSpacerWeight = (1 - progress * 2).coerceAtLeast(0f)
+
+                // Adding +1 or -1 for cases when start and end have the same coordinates -
+                // otherwise on APIs <= 26 line will not be drawn
+                val additionalPixel = if (isRtl) -1 else 1
+
+                val start = Offset(
+                    startX + drawWidth * (if (isRtl) startSpacerWeight else endSpacerWeight) +
+                        additionalPixel,
+                    this.size.height / 2
+                )
+                val end = Offset(
+                    endX - drawWidth * (if (isRtl) endSpacerWeight else startSpacerWeight),
+                    this.size.height / 2
+                )
+                onDrawBehind {
+                    drawLine(
+                        color = selectedColor,
+                        start = start,
+                        end = end,
+                        cap = StrokeCap.Round,
+                        strokeWidth = strokeWidth
+                    )
+                }
+            }
+    )
+}
+
+@Composable
+private fun LinearIndicator(
+    page: Int,
+    pagesState: PagesState,
+    unselectedColor: Color,
+    indicatorSize: Dp,
+    spacing: Dp,
+) {
+    Spacer(
+        modifier = Modifier
+            .padding(horizontal = spacing / 2)
+            .size(indicatorSize)
+            .drawWithCache {
+                val strokeWidth = indicatorSize.toPx() * pagesState.sizeRatio(page)
+                val start = Offset(strokeWidth / 2 + 1, this.size.height / 2)
+                val end = Offset(strokeWidth / 2, this.size.height / 2)
+                onDrawBehind {
+                    drawLine(
+                        color = unselectedColor,
+                        start = start,
+                        end = end,
+                        cap = StrokeCap.Round,
+                        alpha = pagesState.alpha(page),
+                        strokeWidth = strokeWidth
+                    )
+                }
+            }
+    )
+}
+
+@Composable
+private fun LinearSpacer(leftSpacerSize: Dp) {
+    Spacer(Modifier.size(leftSpacerSize, 0.dp))
+}
+
+@Composable
+private fun CurvedPageIndicator(
+    modifier: Modifier,
+    visibleDotIndex: Int,
+    pagesOnScreen: Int,
+    @Suppress("PrimitiveInLambda")
+    indicator: CurvedScope.(Int) -> Unit,
+    itemsSpacer: CurvedScope.() -> Unit,
+    selectedIndicator: CurvedScope.() -> Unit,
+    spacerLeft: CurvedScope.() -> Unit,
+    spacerRight: CurvedScope.() -> Unit
+) {
+    CurvedLayout(
+        modifier = modifier,
+        // 90 degrees equals to 6 o'clock position, at the bottom of the screen
+        anchor = 90f,
+        angularDirection = CurvedDirection.Angular.Reversed
+    ) {
+        // drawing 1 extra spacer for transition
+        spacerLeft()
+
+        curvedRow(radialAlignment = CurvedAlignment.Radial.Center) {
+            for (page in 0 until visibleDotIndex) {
+                indicator(page)
+                itemsSpacer()
+            }
+            curvedBox(radialAlignment = CurvedAlignment.Radial.Center) {
+                curvedRow(radialAlignment = CurvedAlignment.Radial.Center) {
+                    indicator(visibleDotIndex)
+                    itemsSpacer()
+                    indicator(visibleDotIndex + 1)
+                }
+                selectedIndicator()
+            }
+            for (page in visibleDotIndex + 2..pagesOnScreen) {
+                itemsSpacer()
+                indicator(page)
+            }
+        }
+        spacerRight()
+    }
+}
+
+private fun CurvedScope.curvedSelectedIndicator(
+    indicatorSize: Dp,
+    spacing: Dp,
+    selectedColor: Color,
+    progress: Float
+) {
+
+    val startSpacerWeight = (1 - progress * 2).coerceAtLeast(0f)
+    val endSpacerWeight = (progress * 2 - 1).coerceAtLeast(0f)
+    val blurbWeight = (1 - startSpacerWeight - endSpacerWeight).coerceAtLeast(0.01f)
+
+    // Add 0.5dp to cover the sweepDegrees of unselected indicators
+    curvedRow(CurvedModifier.angularSizeDp(spacing + indicatorSize + 0.5.dp)) {
+        if (endSpacerWeight > 0f) {
+            curvedRow(CurvedModifier.weight(endSpacerWeight)) { }
+        }
+        curvedRow(
+            CurvedModifier
+                .background(selectedColor, cap = StrokeCap.Round)
+                .weight(blurbWeight)
+                // Adding 0.3dp to fully cover edges of non-selected indicators
+                .radialSize(indicatorSize + 0.3.dp)
+        ) { }
+        if (startSpacerWeight > 0f) {
+            curvedRow(CurvedModifier.weight(startSpacerWeight)) { }
+        }
+    }
+}
+
+private fun CurvedScope.curvedIndicator(
+    page: Int,
+    unselectedColor: Color,
+    pagesState: PagesState,
+    size: Dp
+) {
+    curvedBox(
+        CurvedModifier
+            // Ideally we want sweepDegrees to be = 0f, because the circular shape is drawn
+            // by the Round StrokeCap.
+            // But it can't have 0f value due to limitations of underlying Canvas.
+            // Values below 0.2f also give some artifacts b/291753164
+            .size(0.2f, size * pagesState.sizeRatio(page))
+            .background(
+                color = unselectedColor.copy(
+                    alpha = unselectedColor.alpha * pagesState.alpha(page)
+                ),
+                cap = StrokeCap.Round
+            )
+    ) { }
+}
+
+private fun CurvedScope.curvedSpacer(size: Dp) {
+    curvedBox(CurvedModifier.angularSizeDp(size).radialSize(0.dp)) { }
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
index 9563288..f468dab 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Slider.kt
@@ -99,6 +99,7 @@
  * @param colors [InlineSliderColors] that will be used to resolve the background and content color
  * for this slider in different states.
  */
+@ExperimentalWearMaterial3Api
 @Composable
 fun InlineSlider(
     value: Float,
@@ -254,6 +255,7 @@
  * @param colors [InlineSliderColors] that will be used to resolve the background and content color
  * for this slider in different states.
  */
+@ExperimentalWearMaterial3Api
 @Composable
 fun InlineSlider(
     value: Int,
@@ -282,6 +284,7 @@
 }
 
 /** Defaults used by slider. */
+@ExperimentalWearMaterial3Api
 object InlineSliderDefaults {
     /**
      * Default slider measurements.
@@ -374,6 +377,7 @@
  * @param disabledUnselectedBarColor The background color of the progress bar when disabled.
  * @param disabledBarSeparatorColor The color of separator between visible segments when disabled.
  */
+@ExperimentalWearMaterial3Api
 @Immutable
 class InlineSliderColors constructor(
     val containerColor: Color,
@@ -453,6 +457,7 @@
     }
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 internal fun DrawScope.drawSelectedProgressBar(
     color: Color,
     valueRatio: Float,
@@ -470,6 +475,7 @@
     )
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 internal fun DrawScope.drawUnselectedProgressBar(
     color: Color,
     valueRatio: Float,
@@ -486,6 +492,7 @@
     )
 }
 
+@OptIn(ExperimentalWearMaterial3Api::class)
 internal fun DrawScope.drawProgressBarSeparator(color: Color, position: Float) {
     drawCircle(
         color = color,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Stepper.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Stepper.kt
index a233927..2c71a42 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Stepper.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/Stepper.kt
@@ -63,6 +63,7 @@
  * that defaults to [contentColor], unless specifically overridden.
  * @param content Content body for the Stepper.
  */
+@ExperimentalWearMaterial3Api
 @Composable
 fun Stepper(
     value: Float,
@@ -143,6 +144,7 @@
  * that defaults to [contentColor], unless specifically overridden.
  * @param content Content body for the Stepper.
  */
+@ExperimentalWearMaterial3Api
 @Composable
 fun Stepper(
     value: Int,
@@ -174,6 +176,7 @@
 /**
  * Defaults used by stepper.
  */
+@ExperimentalWearMaterial3Api
 public object StepperDefaults {
     /**
      * Decrease [ImageVector].
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
index d31eba6..6bc0125 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/TextButton.kt
@@ -39,8 +39,7 @@
  * to ensure that the recommended minimum touch target size is available.
  *
  * The recommended [TextButton] sizes are [TextButtonDefaults.DefaultButtonSize],
- * [TextButtonDefaults.LargeButtonSize], [TextButtonDefaults.SmallButtonSize] and
- * [TextButtonDefaults.ExtraSmallButtonSize].
+ * [TextButtonDefaults.LargeButtonSize] and [TextButtonDefaults.SmallButtonSize].
  *
  * The default [TextButton] has no border and a transparent background for low emphasis actions.
  * For actions that require high emphasis, set [colors] to
@@ -109,8 +108,7 @@
  * Set the size of the [TextToggleButton] with Modifier.[touchTargetAwareSize]
  * to ensure that the background padding will correctly reach the edge of the minimum touch target.
  * The recommended text button sizes are [TextButtonDefaults.DefaultButtonSize],
- * [TextButtonDefaults.LargeButtonSize], [TextButtonDefaults.SmallButtonSize] and
- * [TextButtonDefaults.ExtraSmallButtonSize].
+ * [TextButtonDefaults.LargeButtonSize] and [TextButtonDefaults.SmallButtonSize].
  *
  * [TextToggleButton] can be enabled or disabled. A disabled button will not respond to
  * click events. When enabled, the checked and unchecked events are propagated by [onCheckedChange].
@@ -318,12 +316,6 @@
     }
 
     /**
-     * The recommended background size of an extra small, compact button.
-     * It is recommended to apply this size using [Modifier.touchTargetAwareSize].
-     */
-    val ExtraSmallButtonSize = 32.dp
-
-    /**
      * The recommended size for a small button.
      * It is recommended to apply this size using [Modifier.touchTargetAwareSize].
      */
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSource.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSource.java
index f4bb02b..4b56178 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSource.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSource.java
@@ -34,6 +34,7 @@
             new ArrayList<>();
     @NonNull private final Supplier<Instant> mClock;
     @Nullable private final PlatformTimeUpdateNotifier mUpdateNotifier;
+    private int mPendingConsumers = 0;
 
     EpochTimePlatformDataSource(
             @NonNull Supplier<Instant> clock,
@@ -43,11 +44,25 @@
     }
 
     @UiThread
+    void preRegister(){
+        mPendingConsumers++;
+    }
+
+    @UiThread
     void registerForData(DynamicTypeValueReceiverWithPreUpdate<Instant> consumer) {
+        mPendingConsumers--;
         if (mConsumerToTimeCallback.isEmpty() && mUpdateNotifier != null) {
             mUpdateNotifier.setReceiver(mExecutor, this::tick);
         }
         mConsumerToTimeCallback.add(consumer);
+
+        if (mPendingConsumers == 0){
+            // After all registrations, trigger a tick so that new consumers don't end up waiting
+            // for the next scheduled tick.
+            // We might end up calling this twice for the very first receiver registration. But
+            // that shouldn't cause any issues.
+            tick();
+        }
     }
 
     @UiThread
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 3144be8..4cebacd 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
@@ -68,7 +68,11 @@
 
         @Override
         @UiThread
-        public void preInit() {}
+        public void preInit() {
+            if (mEpochTimePlatformDataSource != null) {
+                mEpochTimePlatformDataSource.preRegister();
+            }
+        }
 
         @Override
         @UiThread
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImpl.java b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImpl.java
index 991a300..a4ea246 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImpl.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/main/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImpl.java
@@ -43,6 +43,10 @@
     private boolean mUpdatesEnabled = true;
     @Nullable private Executor mRegisteredExecutor;
 
+    /**
+     * Sets the callback to be called whenever platform time needs to be reevaluated. Note that this
+     * doesn't call the callback immediately.
+     */
     @Override
     public void setReceiver(@NonNull Executor executor, @NonNull Runnable tick) {
         if (mRegisteredReceiver != null) {
@@ -56,7 +60,6 @@
             // Send first update and schedule next.
             mLastScheduleTimeMillis = SystemClock.uptimeMillis();
             scheduleNextSecond();
-            runReceiver();
         }
     }
 
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 d1fcf4c..892e466 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
@@ -78,17 +78,10 @@
         notifier.callReceiver();
         assertThat(results).isEmpty();
 
-        // Start evaluation.
+        // Start evaluation. This will send the initial result.
         boundDynamicType.startEvaluation();
 
-        // Trigger reevaluation, which should send a result.
-        for (int i = 0; i < 5; i++) {
-            notifier.callReceiver();
-            assertThat(results).hasSize(i + 1);
-            assertThat(Integer.parseInt(results.get(i))).isAtLeast(0);
-            assertThat(Integer.parseInt(results.get(i))).isLessThan(60);
-        }
-
+        assertThat(results).hasSize(1);
         boundDynamicType.close();
     }
 
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSourceTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSourceTest.java
new file mode 100644
index 0000000..50cf167
--- /dev/null
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/EpochTimePlatformDataSourceTest.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 static com.google.common.truth.Truth.assertThat;
+
+import androidx.annotation.NonNull;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+@RunWith(AndroidJUnit4.class)
+public class EpochTimePlatformDataSourceTest {
+    EpochTimePlatformDataSource platformDataSource =
+            new EpochTimePlatformDataSource(
+                    Instant::now,
+                    new PlatformTimeUpdateNotifier() {
+                        @Override
+                        public void setReceiver(
+                                @NonNull Executor executor, @NonNull Runnable tick) {}
+
+                        @Override
+                        public void clearReceiver() {}
+                    });
+
+    @Test
+    public void afterRegistration_callbackIsCalledOnce() {
+        List<Instant> consumer1 = new ArrayList<>();
+        List<Instant> consumer2 = new ArrayList<>();
+
+        platformDataSource.preRegister();
+        platformDataSource.preRegister();
+        platformDataSource.registerForData(new AddToListCallback<>(consumer1));
+        assertThat(consumer1).isEmpty();
+        platformDataSource.registerForData(new AddToListCallback<>(consumer2));
+
+        assertThat(consumer1).isNotEmpty();
+        assertThat(consumer2).isNotEmpty();
+    }
+
+    @Test
+    public void newRegistration_callbackIsCalledAgain() {
+        List<Instant> consumer1 = new ArrayList<>();
+        List<Instant> consumer2 = new ArrayList<>();
+
+        platformDataSource.preRegister();
+        platformDataSource.registerForData(new AddToListCallback<>(consumer1));
+
+        assertThat(consumer1).isNotEmpty();
+
+        platformDataSource.preRegister();
+        platformDataSource.registerForData(new AddToListCallback<>(consumer2));
+
+        assertThat(consumer2).isNotEmpty();
+    }
+}
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
index 70cc286..bd1a3b1 100644
--- 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
@@ -65,7 +65,6 @@
 
         ArgumentCaptor<Runnable> receiverCaptor = ArgumentCaptor.forClass(Runnable.class);
         verify(notifier).setReceiver(any(), receiverCaptor.capture());
-        receiverCaptor.getValue().run(); // Ticking.
         assertThat(results).containsExactly(Instant.ofEpochSecond(1234567L));
 
         node.destroy();
diff --git a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImplTest.java b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImplTest.java
index f0e1fe3..1121215 100644
--- a/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImplTest.java
+++ b/wear/protolayout/protolayout-expression-pipeline/src/test/java/androidx/wear/protolayout/expression/pipeline/PlatformTimeUpdateNotifierImplTest.java
@@ -57,10 +57,6 @@
         mNotifierUnderTest.setReceiver(mExecutor, mTick);
         mMainLooper.idle();
 
-        // First callback to initialize clients
-        verify(mTick).run();
-        reset();
-
         for (int i = 0; i < 5; i++) {
             runFor(500);
             verifyNoInteractions(mTick);
@@ -141,10 +137,6 @@
         mNotifierUnderTest.setReceiver(mExecutor, mTick);
         mMainLooper.idle();
 
-        // First callback to initialize clients
-        verify(mTick).run();
-        reset();
-
         // Advance by a few seconds...
         long advanceBy = 5500;
         long nextTimeMillis = SystemClock.uptimeMillis() + advanceBy;
@@ -169,10 +161,6 @@
         mNotifierUnderTest.setReceiver(mExecutor, mTick);
         mMainLooper.idle();
 
-        // First callback to initialize clients
-        verify(mTick).run();
-        reset();
-
         mNotifierUnderTest.setReceiver(mExecutor, () -> {});
 
         runFor(2000);
diff --git a/wear/protolayout/protolayout-renderer/build.gradle b/wear/protolayout/protolayout-renderer/build.gradle
index b483e2a..8bc838e 100644
--- a/wear/protolayout/protolayout-renderer/build.gradle
+++ b/wear/protolayout/protolayout-renderer/build.gradle
@@ -34,6 +34,7 @@
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     implementation("androidx.core:core:1.7.0")
     implementation("androidx.wear:wear:1.3.0")
+    implementation("androidx.vectordrawable:vectordrawable-seekable:1.0.0-beta01")
 
     testImplementation(libs.mockitoCore4)
     testImplementation(libs.testExtJunit)
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/SeekableAnimatedVectorDrawable.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/SeekableAnimatedVectorDrawable.java
deleted file mode 100644
index 4680873..0000000
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/SeekableAnimatedVectorDrawable.java
+++ /dev/null
@@ -1,125 +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.renderer.common;
-
-import android.content.res.Resources;
-import android.content.res.TypedArray;
-import android.graphics.Canvas;
-import android.graphics.ColorFilter;
-import android.graphics.drawable.Drawable;
-import android.util.AttributeSet;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.RestrictTo;
-import androidx.core.content.res.TypedArrayUtils;
-
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
-
-import java.io.IOException;
-
-/**
- * Placeholder SeekableAnimatedVectorDrawable class for temporarily replacing
- * androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable
- */
-@RestrictTo(RestrictTo.Scope.LIBRARY)
-public class SeekableAnimatedVectorDrawable extends Drawable {
-    private static final String ANIMATED_VECTOR = "animated-vector";
-    static final int[] STYLEABLE_ANIMATED_VECTOR_DRAWABLE = {
-            android.R.attr.drawable
-    };
-    static final int STYLEABLE_ANIMATED_VECTOR_DRAWABLE_DRAWABLE = 0;
-
-    private final Drawable mDrawable;
-    private long mTotalDuration = 0;
-    private long mCurrentPlayTime = 0;
-
-    SeekableAnimatedVectorDrawable(@NonNull Drawable drawable) {
-       mDrawable = drawable;
-    }
-
-    @NonNull
-    public static SeekableAnimatedVectorDrawable createFromXmlInner(
-            @NonNull Resources res,
-            @NonNull XmlPullParser parser,
-            @NonNull AttributeSet attrs,
-            @Nullable Resources.Theme theme
-    ) throws XmlPullParserException, IOException {
-        int eventType = parser.getEventType();
-        final int innerDepth = parser.getDepth() + 1;
-
-        // Parse everything until the end of the animated-vector element.
-        while (eventType != XmlPullParser.END_DOCUMENT
-                && (parser.getDepth() >= innerDepth || eventType != XmlPullParser.END_TAG)) {
-            if (eventType == XmlPullParser.START_TAG) {
-                final String tagName = parser.getName();
-                 if (ANIMATED_VECTOR.equals(tagName)) {
-                    final TypedArray a =
-                            TypedArrayUtils.obtainAttributes(res, theme, attrs,
-                                    STYLEABLE_ANIMATED_VECTOR_DRAWABLE);
-
-                    int drawableRes = a.getResourceId(
-                            STYLEABLE_ANIMATED_VECTOR_DRAWABLE_DRAWABLE, 0);
-                    Drawable drawable = res.getDrawable(drawableRes, null);
-                    a.recycle();
-
-                    return new SeekableAnimatedVectorDrawable(drawable);
-
-                }
-            }
-            eventType = parser.next();
-        }
-
-        throw new XmlPullParserException("no animated-vector tag in the resource");
-    }
-
-    public long getTotalDuration() {
-        return mTotalDuration;
-    }
-
-    public void setCurrentPlayTime(long playTime) {
-        mCurrentPlayTime = playTime;
-    }
-
-    public long getCurrentPlayTime() {
-        return mCurrentPlayTime;
-    }
-
-    public void start() {}
-
-    @Override
-    public void draw(@NonNull Canvas canvas) {
-        mDrawable.draw(canvas);
-    }
-
-    @Override
-    public void setAlpha(int i) {
-        mDrawable.setAlpha(i);
-    }
-
-    @Override
-    public void setColorFilter(@Nullable ColorFilter colorFilter) {
-        mDrawable.setColorFilter(colorFilter);
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public int getOpacity() {
-        return mDrawable.getOpacity();
-    }
-}
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
index 84a7180..8483ea6 100644
--- 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
@@ -25,7 +25,7 @@
 import androidx.annotation.UiThread;
 import androidx.annotation.VisibleForTesting;
 import androidx.collection.ArraySet;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.expression.pipeline.BoundDynamicType;
 import androidx.wear.protolayout.expression.pipeline.DynamicTypeBindingRequest;
 import androidx.wear.protolayout.expression.pipeline.QuotaManager;
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
index 7d83286..463af2e 100644
--- 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
@@ -37,7 +37,7 @@
 import androidx.annotation.VisibleForTesting;
 import androidx.collection.ArrayMap;
 import androidx.collection.ArraySet;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.expression.PlatformDataKey;
 import androidx.wear.protolayout.expression.pipeline.BoundDynamicType;
 import androidx.wear.protolayout.expression.pipeline.DynamicTypeBindingRequest;
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/DefaultAndroidSeekableAnimatedImageResourceByResIdResolver.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/DefaultAndroidSeekableAnimatedImageResourceByResIdResolver.java
index 489e8de..eed00eb 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/DefaultAndroidSeekableAnimatedImageResourceByResIdResolver.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/DefaultAndroidSeekableAnimatedImageResourceByResIdResolver.java
@@ -23,7 +23,7 @@
 import android.util.Xml;
 
 import androidx.annotation.NonNull;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.proto.ResourceProto.AndroidSeekableAnimatedImageResourceByResId;
 import androidx.wear.protolayout.proto.ResourceProto.AnimatedImageFormat;
 import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.AndroidSeekableAnimatedImageResourceByResIdResolver;
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
index 7aca4e1..97c1f9a 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
@@ -86,7 +86,7 @@
 import androidx.core.view.AccessibilityDelegateCompat;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.expression.pipeline.AnimationsHelper;
 import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
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
index f76bafa..f4a1cfa 100644
--- 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
@@ -41,7 +41,7 @@
 import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.expression.AppDataKey;
 import androidx.wear.protolayout.expression.DynamicBuilders;
 import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
@@ -94,7 +94,6 @@
 import com.google.common.truth.Expect;
 
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -1106,7 +1105,6 @@
     }
 
     @Test
-    @Ignore("b/286028644")
     public void resolvedSeekableAnimatedImage_canStoreAndRegisterWithAnimatableFixedFloat() {
         ProtoLayoutDynamicDataPipeline pipeline =
                 new ProtoLayoutDynamicDataPipeline(
@@ -1139,7 +1137,6 @@
     }
 
     @Test
-    @Ignore("b/286028644")
     public void resolvedSeekableAnimatedImage_canStoreAndRegisterWithAnimatableDynamicFloat() {
         ProtoLayoutDynamicDataPipeline pipeline =
                 new ProtoLayoutDynamicDataPipeline(
@@ -1185,7 +1182,6 @@
     }
 
     @Test
-    @Ignore("b/286028644")
     public void resolvedSeekableAnimatedImage_getSeekableAnimationTotalDurationMillis() {
         ProtoLayoutDynamicDataPipeline pipeline =
                 new ProtoLayoutDynamicDataPipeline(
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
index 1285360..07d873b 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
@@ -74,7 +74,7 @@
 import androidx.core.content.ContextCompat;
 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.wear.protolayout.renderer.common.SeekableAnimatedVectorDrawable;
+import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
 import androidx.wear.protolayout.expression.AppDataKey;
 import androidx.wear.protolayout.expression.DynamicBuilders;
 import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
@@ -1661,7 +1661,6 @@
     }
 
     @Test
-    @Ignore("b/286028644")
     public void inflate_imageView_withSeekableAVDResource() {
         LayoutElement root =
                 LayoutElement.newBuilder()
diff --git a/window/window/api/1.2.0-beta02.txt b/window/window/api/1.2.0-beta02.txt
index 7c79bde..6ff2519 100644
--- a/window/window/api/1.2.0-beta02.txt
+++ b/window/window/api/1.2.0-beta02.txt
@@ -56,7 +56,7 @@
 
   @SuppressCompatibility @androidx.window.core.ExperimentalWindowApi public final class WindowAreaInfo {
     method public androidx.window.area.WindowAreaSession? getActiveSession(androidx.window.area.WindowAreaCapability.Operation operation);
-    method public androidx.window.area.WindowAreaCapability? getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
+    method public androidx.window.area.WindowAreaCapability getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
     method public androidx.window.layout.WindowMetrics getMetrics();
     method public android.os.Binder getToken();
     method public androidx.window.area.WindowAreaInfo.Type getType();
diff --git a/window/window/api/current.txt b/window/window/api/current.txt
index 7c79bde..6ff2519 100644
--- a/window/window/api/current.txt
+++ b/window/window/api/current.txt
@@ -56,7 +56,7 @@
 
   @SuppressCompatibility @androidx.window.core.ExperimentalWindowApi public final class WindowAreaInfo {
     method public androidx.window.area.WindowAreaSession? getActiveSession(androidx.window.area.WindowAreaCapability.Operation operation);
-    method public androidx.window.area.WindowAreaCapability? getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
+    method public androidx.window.area.WindowAreaCapability getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
     method public androidx.window.layout.WindowMetrics getMetrics();
     method public android.os.Binder getToken();
     method public androidx.window.area.WindowAreaInfo.Type getType();
diff --git a/window/window/api/restricted_1.2.0-beta02.txt b/window/window/api/restricted_1.2.0-beta02.txt
index 7c79bde..6ff2519 100644
--- a/window/window/api/restricted_1.2.0-beta02.txt
+++ b/window/window/api/restricted_1.2.0-beta02.txt
@@ -56,7 +56,7 @@
 
   @SuppressCompatibility @androidx.window.core.ExperimentalWindowApi public final class WindowAreaInfo {
     method public androidx.window.area.WindowAreaSession? getActiveSession(androidx.window.area.WindowAreaCapability.Operation operation);
-    method public androidx.window.area.WindowAreaCapability? getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
+    method public androidx.window.area.WindowAreaCapability getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
     method public androidx.window.layout.WindowMetrics getMetrics();
     method public android.os.Binder getToken();
     method public androidx.window.area.WindowAreaInfo.Type getType();
diff --git a/window/window/api/restricted_current.txt b/window/window/api/restricted_current.txt
index 7c79bde..6ff2519 100644
--- a/window/window/api/restricted_current.txt
+++ b/window/window/api/restricted_current.txt
@@ -56,7 +56,7 @@
 
   @SuppressCompatibility @androidx.window.core.ExperimentalWindowApi public final class WindowAreaInfo {
     method public androidx.window.area.WindowAreaSession? getActiveSession(androidx.window.area.WindowAreaCapability.Operation operation);
-    method public androidx.window.area.WindowAreaCapability? getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
+    method public androidx.window.area.WindowAreaCapability getCapability(androidx.window.area.WindowAreaCapability.Operation operation);
     method public androidx.window.layout.WindowMetrics getMetrics();
     method public android.os.Binder getToken();
     method public androidx.window.area.WindowAreaInfo.Type getType();
diff --git a/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt b/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
index 92b327a..1fff538 100644
--- a/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
+++ b/window/window/src/androidTest/java/androidx/window/area/WindowAreaControllerImplTest.kt
@@ -181,7 +181,7 @@
 
         assertNotNull(windowAreaInfo)
         assertEquals(
-            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA)?.status,
+            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA).status,
             WINDOW_AREA_STATUS_AVAILABLE
         )
 
@@ -245,7 +245,7 @@
 
         assertNotNull(windowAreaInfo)
         assertEquals(
-            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA)?.status,
+            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA).status,
             WindowAreaAdapter.translate(initialState)
         )
 
@@ -290,7 +290,7 @@
         assertNotNull(windowAreaInfo)
         assertTrue {
             windowAreaInfo
-                .getCapability(OPERATION_PRESENT_ON_AREA)?.status ==
+                .getCapability(OPERATION_PRESENT_ON_AREA).status ==
                 WINDOW_AREA_STATUS_AVAILABLE
         }
 
@@ -341,7 +341,7 @@
         assertNotNull(windowAreaInfo)
         assertTrue {
             windowAreaInfo
-                .getCapability(OPERATION_PRESENT_ON_AREA)?.status ==
+                .getCapability(OPERATION_PRESENT_ON_AREA).status ==
                 WINDOW_AREA_STATUS_AVAILABLE
         }
 
@@ -395,7 +395,7 @@
 
         assertNotNull(windowAreaInfo)
         assertEquals(
-            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA)?.status,
+            windowAreaInfo.getCapability(OPERATION_TRANSFER_ACTIVITY_TO_AREA).status,
             WINDOW_AREA_STATUS_AVAILABLE
         )
 
@@ -454,7 +454,7 @@
         assertNotNull(windowAreaInfo)
         assertTrue {
             windowAreaInfo
-                .getCapability(OPERATION_PRESENT_ON_AREA)?.status ==
+                .getCapability(OPERATION_PRESENT_ON_AREA).status ==
                 WINDOW_AREA_STATUS_UNAVAILABLE
         }
 
diff --git a/window/window/src/main/java/androidx/window/area/WindowAreaInfo.kt b/window/window/src/main/java/androidx/window/area/WindowAreaInfo.kt
index 4aa63a1b..4e031cf3 100644
--- a/window/window/src/main/java/androidx/window/area/WindowAreaInfo.kt
+++ b/window/window/src/main/java/androidx/window/area/WindowAreaInfo.kt
@@ -20,6 +20,7 @@
 import androidx.window.area.WindowAreaCapability.Operation.Companion.OPERATION_PRESENT_ON_AREA
 import androidx.window.area.WindowAreaCapability.Operation.Companion.OPERATION_TRANSFER_ACTIVITY_TO_AREA
 import androidx.window.area.WindowAreaCapability.Status.Companion.WINDOW_AREA_STATUS_ACTIVE
+import androidx.window.area.WindowAreaCapability.Status.Companion.WINDOW_AREA_STATUS_UNSUPPORTED
 import androidx.window.core.ExperimentalWindowApi
 import androidx.window.extensions.area.WindowAreaComponent
 import androidx.window.layout.WindowMetrics
@@ -55,10 +56,14 @@
 
     /**
      * Returns the [WindowAreaCapability] corresponding to the [operation] provided. If this
-     * [WindowAreaCapability] does not exist for this [WindowAreaInfo], null is returned.
+     * [WindowAreaCapability] does not exist for this [WindowAreaInfo], a [WindowAreaCapability]
+     * with a [WINDOW_AREA_STATUS_UNSUPPORTED] value is returned.
      */
-    fun getCapability(operation: WindowAreaCapability.Operation): WindowAreaCapability? {
-        return capabilityMap[operation]
+    fun getCapability(operation: WindowAreaCapability.Operation): WindowAreaCapability {
+        return capabilityMap[operation] ?: WindowAreaCapability(
+            operation,
+            WINDOW_AREA_STATUS_UNSUPPORTED
+        )
     }
 
     /**
@@ -68,7 +73,7 @@
      * @throws IllegalStateException if there is no active session for the provided [operation]
      */
     fun getActiveSession(operation: WindowAreaCapability.Operation): WindowAreaSession? {
-        if (getCapability(operation)?.status != WINDOW_AREA_STATUS_ACTIVE) {
+        if (getCapability(operation).status != WINDOW_AREA_STATUS_ACTIVE) {
             throw IllegalStateException("No session is currently active")
         }
 
diff --git a/work/work-runtime/src/androidTest/java/androidx/work/GreedySchedulerTimeoutTest.kt b/work/work-runtime/src/androidTest/java/androidx/work/GreedySchedulerTimeoutTest.kt
new file mode 100644
index 0000000..1f2c690
--- /dev/null
+++ b/work/work-runtime/src/androidTest/java/androidx/work/GreedySchedulerTimeoutTest.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.work
+
+import android.app.job.JobParameters.STOP_REASON_TIMEOUT
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.work.impl.WorkManagerImpl
+import androidx.work.impl.constraints.trackers.Trackers
+import androidx.work.impl.testutils.TrackingWorkerFactory
+import androidx.work.testutils.GreedyScheduler
+import androidx.work.testutils.TestEnv
+import androidx.work.testutils.WorkManager
+import androidx.work.testutils.launchTester
+import androidx.work.worker.CompletableWorker
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+@SdkSuppress(minSdkVersion = 31)
+class GreedySchedulerTimeoutTest {
+    val workerFactory = TrackingWorkerFactory()
+    private val runnableScheduler = ManualDefaultRunnableScheduler()
+    val configuration = Configuration.Builder()
+        .setRunnableScheduler(runnableScheduler)
+        .setWorkerFactory(workerFactory)
+        .setTaskExecutor(Executors.newSingleThreadExecutor())
+        .build()
+    val env = TestEnv(configuration)
+    val trackers = Trackers(
+        context = env.context,
+        taskExecutor = env.taskExecutor,
+    )
+    val workManager = WorkManager(env, listOf(GreedyScheduler(env, trackers)), trackers)
+
+    init {
+        WorkManagerImpl.setDelegate(workManager)
+    }
+
+    @Test
+    fun testWorkerTimesout() = runBlocking {
+        val request = OneTimeWorkRequest.Builder(CompletableWorker::class.java).build()
+        workManager.enqueue(request).await()
+        val worker = workerFactory.await(request.id)
+        val tester = launchTester(workManager.getWorkInfoByIdFlow(request.id))
+        val runningWorkInfo = tester.awaitNext()
+        assertThat(runningWorkInfo.state).isEqualTo(WorkInfo.State.RUNNING)
+        runnableScheduler.executedFutureRunnables(TimeUnit.HOURS.toMillis(2))
+        val stopInfo = tester.awaitNext()
+        assertThat(stopInfo.state).isEqualTo(WorkInfo.State.ENQUEUED)
+        assertThat(worker.stopReason).isEqualTo(STOP_REASON_TIMEOUT)
+    }
+
+    private class ManualDefaultRunnableScheduler : RunnableScheduler {
+        private val map = mutableMapOf<Runnable, Long>()
+        private val lock = Any()
+
+        override fun scheduleWithDelay(delayInMillis: Long, runnable: Runnable) {
+            synchronized(lock) { map[runnable] = System.currentTimeMillis() + delayInMillis }
+        }
+
+        override fun cancel(runnable: Runnable) {
+            synchronized(lock) { map.remove(runnable) }
+        }
+
+        fun executedFutureRunnables(duration: Long) {
+            val current = System.currentTimeMillis()
+            val runnables = mutableListOf<Runnable>()
+            synchronized(lock) {
+                map.filter { current + duration >= it.value }.keys.toCollection(runnables)
+            }
+            runnables.forEach { it.run() }
+        }
+    }
+}
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
index b1e0662..7f8a419 100644
--- a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/GreedyScheduler.java
@@ -32,6 +32,7 @@
 import androidx.annotation.VisibleForTesting;
 import androidx.work.Configuration;
 import androidx.work.Logger;
+import androidx.work.RunnableScheduler;
 import androidx.work.WorkInfo;
 import androidx.work.impl.ExecutionListener;
 import androidx.work.impl.Processor;
@@ -89,6 +90,7 @@
 
     private final WorkConstraintsTracker mConstraintsTracker;
     private final TaskExecutor mTaskExecutor;
+    private final TimeLimiter mTimeLimiter;
 
     public GreedyScheduler(
             @NonNull Context context,
@@ -99,8 +101,10 @@
             @NonNull TaskExecutor taskExecutor
     ) {
         mContext = context;
-        mDelayedWorkTracker = new DelayedWorkTracker(this, configuration.getRunnableScheduler(),
+        RunnableScheduler runnableScheduler = configuration.getRunnableScheduler();
+        mDelayedWorkTracker = new DelayedWorkTracker(this, runnableScheduler,
                 configuration.getClock());
+        mTimeLimiter = new TimeLimiter(runnableScheduler, workLauncher);
         mTaskExecutor = taskExecutor;
         mConstraintsTracker = new WorkConstraintsTracker(trackers);
         mConfiguration = configuration;
@@ -170,7 +174,9 @@
                     // it doesn't help against races, but reduces useless load in the system
                     if (!mStartStopTokens.contains(generationalId(workSpec))) {
                         Logger.get().debug(TAG, "Starting work for " + workSpec.id);
-                        mWorkLauncher.startWork(mStartStopTokens.tokenFor(workSpec));
+                        StartStopToken token = mStartStopTokens.tokenFor(workSpec);
+                        mTimeLimiter.track(token);
+                        mWorkLauncher.startWork(token);
                     }
                 }
             }
@@ -216,6 +222,7 @@
         }
         // onExecutionCompleted does the cleanup.
         for (StartStopToken id : mStartStopTokens.remove(workSpecId)) {
+            mTimeLimiter.cancel(id);
             mWorkLauncher.stopWork(id);
         }
     }
@@ -228,12 +235,15 @@
             // it doesn't help against races, but reduces useless load in the system
             if (!mStartStopTokens.contains(id)) {
                 Logger.get().debug(TAG, "Constraints met: Scheduling work ID " + id);
-                mWorkLauncher.startWork(mStartStopTokens.tokenFor(id));
+                StartStopToken token = mStartStopTokens.tokenFor(id);
+                mTimeLimiter.track(token);
+                mWorkLauncher.startWork(token);
             }
         } else {
             Logger.get().debug(TAG, "Constraints not met: Cancelling work ID " + id);
             StartStopToken runId = mStartStopTokens.remove(id);
             if (runId != null) {
+                mTimeLimiter.cancel(runId);
                 int reason = ((ConstraintsState.ConstraintsNotMet) state).reasonInt();
                 mWorkLauncher.stopWorkWithReason(runId, reason);
             }
@@ -242,7 +252,10 @@
 
     @Override
     public void onExecuted(@NonNull WorkGenerationalId id, boolean needsReschedule) {
-        mStartStopTokens.remove(id);
+        StartStopToken token = mStartStopTokens.remove(id);
+        if (token != null) {
+            mTimeLimiter.cancel(token);
+        }
         removeConstraintTrackingFor(id);
 
         if (!needsReschedule) {
diff --git a/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/TimeLimiter.kt b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/TimeLimiter.kt
new file mode 100644
index 0000000..fb946b8
--- /dev/null
+++ b/work/work-runtime/src/main/java/androidx/work/impl/background/greedy/TimeLimiter.kt
@@ -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.work.impl.background.greedy
+
+import android.app.job.JobParameters
+import androidx.work.RunnableScheduler
+import androidx.work.StopReason
+import androidx.work.impl.StartStopToken
+import androidx.work.impl.WorkLauncher
+import java.util.concurrent.TimeUnit
+
+internal class TimeLimiter @JvmOverloads constructor(
+    private val runnableScheduler: RunnableScheduler,
+    private val launcher: WorkLauncher,
+    private val timeoutMs: Long = TimeUnit.MINUTES.toMillis(90),
+) {
+    private val lock = Any()
+    private val tracked = mutableMapOf<StartStopToken, Runnable>()
+
+    fun track(token: StartStopToken) {
+        val stopRunnable = Runnable {
+            launcher.stopWork(token, StopReason(JobParameters.STOP_REASON_TIMEOUT))
+        }
+        synchronized(lock) { tracked.put(token, stopRunnable) }
+        runnableScheduler.scheduleWithDelay(timeoutMs, stopRunnable)
+    }
+
+    fun cancel(token: StartStopToken) {
+        synchronized(lock) { tracked.remove(token) }?.let { runnableScheduler.cancel(it) }
+    }
+}