Merge "Fix CJK composition bug in BasicTextField2" into androidx-main
diff --git a/busytown/androidx.sh b/busytown/androidx.sh
index 19e56cd..20ea0f3 100755
--- a/busytown/androidx.sh
+++ b/busytown/androidx.sh
@@ -23,12 +23,11 @@
       -Pandroidx.enableComposeCompilerMetrics=true \
       -Pandroidx.enableComposeCompilerReports=true \
       -Pandroidx.constraints=true \
-      --no-daemon \
-      --profile "$@"; then
+      --no-daemon "$@"; then
     EXIT_VALUE=1
   fi
 
-  # Parse performance profile reports (generated with the --profile option above) and re-export
+  # Parse performance profile reports (generated with the --profile option) and re-export
   # the metrics in an easily machine-readable format for tracking
   impl/parse_profile_data.sh
 fi
diff --git a/busytown/androidx_incremental.sh b/busytown/androidx_incremental.sh
index 775d422..949b04b 100755
--- a/busytown/androidx_incremental.sh
+++ b/busytown/androidx_incremental.sh
@@ -64,7 +64,6 @@
 else
     # Run Gradle
     if impl/build.sh $DIAGNOSE_ARG buildOnServer checkExternalLicenses listTaskOutputs exportSboms \
-        --profile \
         "$@"; then
     echo build succeeded
     EXIT_VALUE=0
@@ -73,7 +72,7 @@
     EXIT_VALUE=1
     fi
 
-    # Parse performance profile reports (generated with the --profile option above) and re-export the metrics in an easily machine-readable format for tracking
+    # Parse performance profile reports (generated with the --profile option) and re-export the metrics in an easily machine-readable format for tracking
     impl/parse_profile_data.sh
 fi
 
diff --git a/busytown/impl/build-studio-and-androidx.sh b/busytown/impl/build-studio-and-androidx.sh
index d37dd88..1511b7c 100755
--- a/busytown/impl/build-studio-and-androidx.sh
+++ b/busytown/impl/build-studio-and-androidx.sh
@@ -99,5 +99,5 @@
   export USE_ANDROIDX_REMOTE_BUILD_CACHE=gcp
 fi
 
-$SCRIPTS_DIR/impl/build.sh $androidxArguments --profile --dependency-verification=off -Pandroidx.validateNoUnrecognizedMessages=false
+$SCRIPTS_DIR/impl/build.sh $androidxArguments --dependency-verification=off -Pandroidx.validateNoUnrecognizedMessages=false
 echo "Completing $0 at $(date)"
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
index 6d83e9f..d524379 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
@@ -56,6 +56,7 @@
 import androidx.camera.core.ExposureState;
 import androidx.camera.core.FocusMeteringAction;
 import androidx.camera.core.Logger;
+import androidx.camera.core.PhysicalCameraInfo;
 import androidx.camera.core.ZoomState;
 import androidx.camera.core.impl.CameraCaptureCallback;
 import androidx.camera.core.impl.CameraInfoInternal;
@@ -125,6 +126,9 @@
     @NonNull
     private final CameraManagerCompat mCameraManager;
 
+    @Nullable
+    private Set<PhysicalCameraInfo> mPhysicalCameraInfos;
+
     /**
      * Constructs an instance. Before {@link #linkWithCameraControl(Camera2CameraControlImpl)} is
      * called, camera control related API (torch/exposure/zoom) will return default values.
@@ -627,6 +631,32 @@
         return map;
     }
 
+    @NonNull
+    @Override
+    public Set<PhysicalCameraInfo> getPhysicalCameraInfos() {
+        if (mPhysicalCameraInfos == null) {
+            mPhysicalCameraInfos = new HashSet<>();
+            for (String physicalCameraId : mCameraCharacteristicsCompat.getPhysicalCameraIds()) {
+                CameraCharacteristicsCompat characteristicsCompat;
+                try {
+                    characteristicsCompat =
+                            mCameraManager.getCameraCharacteristicsCompat(physicalCameraId);
+                } catch (CameraAccessExceptionCompat e) {
+                    Logger.e(TAG,
+                            "Failed to get CameraCharacteristics for cameraId " + physicalCameraId,
+                            e);
+                    return Collections.emptySet();
+                }
+
+                PhysicalCameraInfo physicalCameraInfo = Camera2PhysicalCameraInfo.of(
+                        physicalCameraId, characteristicsCompat);
+                mPhysicalCameraInfos.add(physicalCameraInfo);
+            }
+        }
+
+        return mPhysicalCameraInfos;
+    }
+
     /**
      * A {@link LiveData} which can be redirected to another {@link LiveData}. If no redirection
      * is set, initial value will be used.
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2PhysicalCameraInfo.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2PhysicalCameraInfo.java
new file mode 100644
index 0000000..2bf301e
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2PhysicalCameraInfo.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal;
+
+import android.hardware.camera2.CameraCharacteristics;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.camera.camera2.internal.compat.CameraCharacteristicsCompat;
+import androidx.camera.core.PhysicalCameraInfo;
+import androidx.core.util.Preconditions;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * Camera2 implementation of {@link PhysicalCameraInfo} which wraps physical camera id and
+ * camera characteristics.
+ */
+@RequiresApi(21)
+@AutoValue
+abstract class Camera2PhysicalCameraInfo implements PhysicalCameraInfo {
+
+    @NonNull
+    @Override
+    public abstract String getPhysicalCameraId();
+
+    @NonNull
+    public abstract CameraCharacteristicsCompat getCameraCharacteristicsCompat();
+
+    @RequiresApi(28)
+    @NonNull
+    @Override
+    public Integer getLensPoseReference() {
+        Integer lensPoseRef =
+                getCameraCharacteristicsCompat().get(CameraCharacteristics.LENS_POSE_REFERENCE);
+        Preconditions.checkNotNull(lensPoseRef);
+        return lensPoseRef;
+    }
+
+    /**
+     * Creates {@link Camera2PhysicalCameraInfo} instance.
+     *
+     * @param physicalCameraId physical camera id.
+     * @param cameraCharacteristicsCompat {@link CameraCharacteristicsCompat}.
+     * @return
+     */
+    @NonNull
+    public static Camera2PhysicalCameraInfo of(
+            @NonNull String physicalCameraId,
+            @NonNull CameraCharacteristicsCompat cameraCharacteristicsCompat) {
+        return new AutoValue_Camera2PhysicalCameraInfo(
+                physicalCameraId, cameraCharacteristicsCompat);
+    }
+}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
index 1932f6a..a47d41c 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
@@ -62,6 +62,7 @@
 import androidx.camera.core.DynamicRange;
 import androidx.camera.core.ExposureState;
 import androidx.camera.core.FocusMeteringAction;
+import androidx.camera.core.PhysicalCameraInfo;
 import androidx.camera.core.SurfaceOrientedMeteringPointFactory;
 import androidx.camera.core.TorchState;
 import androidx.camera.core.ZoomState;
@@ -86,6 +87,7 @@
 import org.robolectric.shadows.StreamConfigurationMapBuilder;
 import org.robolectric.util.ReflectionHelpers;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -515,6 +517,34 @@
         assertThat(map.get("3")).isSameInstanceAs(characteristicsPhysical3);
     }
 
+    @Config(minSdk = 28)
+    @RequiresApi(28)
+    @Test
+    public void canReturnPhysicalCameraInfos()
+            throws CameraAccessExceptionCompat {
+        init(/* hasAvailableCapabilities = */ true);
+
+        CameraCharacteristics characteristics0 = mock(CameraCharacteristics.class);
+        CameraCharacteristics characteristicsPhysical2 = mock(CameraCharacteristics.class);
+        CameraCharacteristics characteristicsPhysical3 = mock(CameraCharacteristics.class);
+        when(characteristics0.getPhysicalCameraIds())
+                .thenReturn(new HashSet<>(Arrays.asList("0", "2", "3")));
+        CameraManagerCompat cameraManagerCompat = initCameraManagerWithPhysicalIds(
+                Arrays.asList(
+                        new Pair<>("0", characteristics0),
+                        new Pair<>("2", characteristicsPhysical2),
+                        new Pair<>("3", characteristicsPhysical3)));
+        Camera2CameraInfoImpl impl = new Camera2CameraInfoImpl("0", cameraManagerCompat);
+
+        List<PhysicalCameraInfo> physicalCameraInfos = new ArrayList<>(
+                impl.getPhysicalCameraInfos());
+        assertThat(physicalCameraInfos.size()).isEqualTo(3);
+        assertThat(characteristics0.getPhysicalCameraIds()).containsExactly(
+                physicalCameraInfos.get(0).getPhysicalCameraId(),
+                physicalCameraInfos.get(1).getPhysicalCameraId(),
+                physicalCameraInfos.get(2).getPhysicalCameraId());
+    }
+
     @Config(maxSdk = 27)
     @Test
     public void canReturnCameraCharacteristicsMapWithMainCamera()
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
index 7d71a4f..1ae1ec4 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
@@ -405,6 +405,17 @@
                 Collections.singleton(DynamicRange.SDR));
     }
 
+    /**
+     * Returns a set of {@link PhysicalCameraInfo}.
+     *
+     * @return Set of {@link PhysicalCameraInfo}.
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    default Set<PhysicalCameraInfo> getPhysicalCameraInfos() {
+        return Collections.emptySet();
+    }
+
     @StringDef(open = true, value = {IMPLEMENTATION_TYPE_UNKNOWN,
             IMPLEMENTATION_TYPE_CAMERA2_LEGACY, IMPLEMENTATION_TYPE_CAMERA2,
             IMPLEMENTATION_TYPE_FAKE})
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraSelector.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraSelector.java
index 240375f..ca80c82 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraSelector.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraSelector.java
@@ -66,10 +66,16 @@
     public static final CameraSelector DEFAULT_BACK_CAMERA =
             new CameraSelector.Builder().requireLensFacing(LENS_FACING_BACK).build();
 
-    private LinkedHashSet<CameraFilter> mCameraFilterSet;
+    @NonNull
+    private final LinkedHashSet<CameraFilter> mCameraFilterSet;
 
-    CameraSelector(LinkedHashSet<CameraFilter> cameraFilterSet) {
+    @Nullable
+    private final String mPhysicalCameraId;
+
+    CameraSelector(@NonNull LinkedHashSet<CameraFilter> cameraFilterSet,
+            @Nullable String physicalCameraId) {
         mCameraFilterSet = cameraFilterSet;
+        mPhysicalCameraId = physicalCameraId;
     }
 
     /**
@@ -204,10 +210,25 @@
         return currentLensFacing;
     }
 
+    /**
+     * Returns the physical camera id.
+     *
+     * @return physical camera id.
+     */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public String getPhysicalCameraId() {
+        return mPhysicalCameraId;
+    }
+
     /** Builder for a {@link CameraSelector}. */
     public static final class Builder {
+        @NonNull
         private final LinkedHashSet<CameraFilter> mCameraFilterSet;
 
+        @Nullable
+        private String mPhysicalCameraId;
+
         public Builder() {
             mCameraFilterSet = new LinkedHashSet<>();
         }
@@ -270,10 +291,23 @@
             return builder;
         }
 
+        /**
+         * Sets the physical camera id.
+         *
+         * @param physicalCameraId physical camera id.
+         * @return this builder.
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public Builder setPhysicalCameraId(@NonNull String physicalCameraId) {
+            mPhysicalCameraId = physicalCameraId;
+            return this;
+        }
+
         /** Builds the {@link CameraSelector}. */
         @NonNull
         public CameraSelector build() {
-            return new CameraSelector(mCameraFilterSet);
+            return new CameraSelector(mCameraFilterSet, mPhysicalCameraId);
         }
     }
 
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
index ed8b596..346b571 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageAnalysis.java
@@ -392,7 +392,7 @@
 
         sessionConfigBuilder.setExpectedFrameRateRange(streamSpec.getExpectedFrameRateRange());
 
-        sessionConfigBuilder.addSurface(mDeferrableSurface, streamSpec.getDynamicRange());
+        sessionConfigBuilder.addSurface(mDeferrableSurface, streamSpec.getDynamicRange(), null);
 
         sessionConfigBuilder.addErrorListener((sessionConfig, error) -> {
             clearPipeline();
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/PhysicalCameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/PhysicalCameraInfo.java
new file mode 100644
index 0000000..1ab2035e
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/PhysicalCameraInfo.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.RestrictTo;
+
+/**
+ * An interface for retrieving physical camera information.
+ *
+ * <p>Applications can retrieve physical camera information via
+ * {@link CameraInfo#getPhysicalCameraInfos()}. As a comparison, {@link CameraInfo} represents
+ * logical camera information. A logical camera is a grouping of two or more of those physical
+ * cameras.
+ *
+ * <p>See <a href="https://developer.android.com/media/camera/camera2/multi-camera">Multi-camera API</a>
+ * for more information.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@RequiresApi(21)
+public interface PhysicalCameraInfo {
+
+    /**
+     * Returns physical camera id.
+     *
+     * @return physical camera id.
+     */
+    @NonNull
+    String getPhysicalCameraId();
+
+    /**
+     * Returns {@link android.hardware.camera2.CameraCharacteristics#LENS_POSE_REFERENCE}.
+     *
+     * @return lens pose reference.
+     */
+    @RequiresApi(28)
+    @NonNull
+    Integer getLensPoseReference();
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/Preview.java b/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
index 02d910b..77899224 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/Preview.java
@@ -332,7 +332,8 @@
         // output target for these two cases.
         if (mSurfaceProvider != null) {
             sessionConfigBuilder.addSurface(mSessionDeferrableSurface,
-                    streamSpec.getDynamicRange());
+                    streamSpec.getDynamicRange(),
+                    getPhysicalCameraId());
         }
 
         sessionConfigBuilder.addErrorListener((sessionConfig, error) -> {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java b/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
index bd07576..8bd0481 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/UseCase.java
@@ -152,6 +152,9 @@
     @Nullable
     private CameraEffect mEffect;
 
+    @Nullable
+    private String mPhysicalCameraId;
+
     ////////////////////////////////////////////////////////////////////////////////////////////
     // [UseCase attached dynamic] - Can change but is only available when the UseCase is attached.
     ////////////////////////////////////////////////////////////////////////////////////////////
@@ -362,6 +365,17 @@
         }
     }
 
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public void setPhysicalCameraId(@NonNull String physicalCameraId) {
+        mPhysicalCameraId = physicalCameraId;
+    }
+
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @Nullable
+    public String getPhysicalCameraId() {
+        return mPhysicalCameraId;
+    }
+
     /**
      * Updates the target rotation of the use case config.
      *
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
index 7d101c05..1cd1453 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
@@ -28,6 +28,7 @@
 import androidx.camera.core.ExperimentalZeroShutterLag;
 import androidx.camera.core.ExposureState;
 import androidx.camera.core.FocusMeteringAction;
+import androidx.camera.core.PhysicalCameraInfo;
 import androidx.camera.core.ZoomState;
 import androidx.lifecycle.LiveData;
 
@@ -228,4 +229,10 @@
     public Object getPhysicalCameraCharacteristics(@NonNull String physicalCameraId) {
         return mCameraInfoInternal.getPhysicalCameraCharacteristics(physicalCameraId);
     }
+
+    @NonNull
+    @Override
+    public Set<PhysicalCameraInfo> getPhysicalCameraInfos() {
+        return mCameraInfoInternal.getPhysicalCameraInfos();
+    }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
index 63bdf6d..414ddb0 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
@@ -647,7 +647,7 @@
          */
         @NonNull
         public Builder addSurface(@NonNull DeferrableSurface surface) {
-            return addSurface(surface, DynamicRange.SDR);
+            return addSurface(surface, DynamicRange.SDR, null);
         }
 
         /**
@@ -656,8 +656,10 @@
          */
         @NonNull
         public Builder addSurface(@NonNull DeferrableSurface surface,
-                @NonNull DynamicRange dynamicRange) {
+                @NonNull DynamicRange dynamicRange,
+                @Nullable String physicalCameraId) {
             OutputConfig outputConfig = OutputConfig.builder(surface)
+                    .setPhysicalCameraId(physicalCameraId)
                     .setDynamicRange(dynamicRange)
                     .build();
             mOutputConfigs.add(outputConfig);
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
index 687a41b..9844499 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
@@ -278,7 +278,7 @@
 
         propagateChildrenCamera2Interop(streamSpec.getResolution(), builder);
 
-        builder.addSurface(mCameraEdge.getDeferrableSurface(), streamSpec.getDynamicRange());
+        builder.addSurface(mCameraEdge.getDeferrableSurface(), streamSpec.getDynamicRange(), null);
         builder.addRepeatingCameraCaptureCallback(
                 mVirtualCameraAdapter.getParentMetadataCallback());
         if (streamSpec.getImplementationOptions() != null) {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
index aa18150..360497e 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
@@ -884,7 +884,7 @@
         DynamicRange dynamicRange = streamSpec.getDynamicRange();
         if (!isStreamError && mDeferrableSurface != null) {
             if (isStreamActive) {
-                sessionConfigBuilder.addSurface(mDeferrableSurface, dynamicRange);
+                sessionConfigBuilder.addSurface(mDeferrableSurface, dynamicRange, null);
             } else {
                 sessionConfigBuilder.addNonRepeatingSurface(mDeferrableSurface, dynamicRange);
             }
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Transition.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Transition.kt
index 585c768..cf8436f 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Transition.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Transition.kt
@@ -34,6 +34,7 @@
 import androidx.compose.runtime.mutableStateListOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshots.SnapshotStateObserver
 import androidx.compose.runtime.withFrameNanos
@@ -52,6 +53,7 @@
 import kotlin.math.roundToLong
 import kotlinx.coroutines.CancellableContinuation
 import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.suspendCancellableCoroutine
@@ -932,7 +934,7 @@
     internal var startTimeNanos by mutableLongStateOf(AnimationConstants.UnspecifiedTime)
 
     // This gets calculated every time child is updated/added
-    internal var updateChildrenNeeded: Boolean by mutableStateOf(true)
+    private var updateChildrenNeeded: Boolean by mutableStateOf(false)
 
     private val _animations = mutableStateListOf<TransitionAnimationState<*, *>>()
     private val _transitions = mutableStateListOf<Transition<*>>()
@@ -1176,21 +1178,34 @@
     internal fun animateTo(targetState: S) {
         if (!isSeeking) {
             updateTarget(targetState)
-            // target != currentState adds LaunchedEffect into the tree in the same frame as
+            // target != currentState adds the effect into the tree in the same frame as
             // target change.
             if (targetState != currentState || isRunning || updateChildrenNeeded) {
-                LaunchedEffect(this) {
-                    while (true) {
+                // We're using a composition-obtained scope + DisposableEffect here to give us
+                // control over coroutine dispatching
+                val coroutineScope = rememberCoroutineScope()
+                DisposableEffect(coroutineScope, this) {
+                    // Launch the coroutine undispatched so the block is executed in the current
+                    // frame. This is important as this initializes the state.
+                    coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
                         val durationScale = coroutineContext.durationScale
                         withFrameNanos {
-                            // This check is very important, as isSeeking may be changed off-band
-                            // between the last check in composition and this callback which
-                            // happens in the animation callback the next frame.
                             if (!isSeeking) {
                                 onFrame(it / AnimationDebugDurationScale, durationScale)
                             }
                         }
+                        while (isRunning) {
+                            withFrameNanos {
+                                // This check is very important, as isSeeking may be changed
+                                // off-band between the last check in composition and this callback
+                                // which happens in the animation callback the next frame.
+                                if (!isSeeking) {
+                                    onFrame(it / AnimationDebugDurationScale, durationScale)
+                                }
+                            }
+                        }
                     }
+                    onDispose { }
                 }
             }
         }
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/VersionChecker.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/VersionChecker.kt
index cdcea5f..62bdfbf 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/VersionChecker.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/VersionChecker.kt
@@ -134,6 +134,7 @@
             12200 to "1.7.0-alpha03",
             12300 to "1.7.0-alpha04",
             12400 to "1.7.0-alpha05",
+            12500 to "1.7.0-alpha06",
         )
 
         /**
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index 032cf78..a94e5bd 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -1748,9 +1748,13 @@
   @androidx.compose.runtime.Stable public final class TextFieldState {
     ctor public TextFieldState(optional String initialText, optional long initialSelection);
     method public inline void edit(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.text.input.TextFieldBuffer,kotlin.Unit> block);
-    method public androidx.compose.foundation.text.input.TextFieldCharSequence getText();
+    method public androidx.compose.ui.text.TextRange? getComposition();
+    method public long getSelection();
+    method public CharSequence getText();
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.text.input.UndoState getUndoState();
-    property public final androidx.compose.foundation.text.input.TextFieldCharSequence text;
+    property public final androidx.compose.ui.text.TextRange? composition;
+    property public final long selection;
+    property public final CharSequence text;
     property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final androidx.compose.foundation.text.input.UndoState undoState;
   }
 
@@ -1762,11 +1766,10 @@
 
   public final class TextFieldStateKt {
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void clearText(androidx.compose.foundation.text.input.TextFieldState);
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static suspend Object? forEachTextValue(androidx.compose.foundation.text.input.TextFieldState, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.text.input.TextFieldCharSequence,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<?>);
     method @androidx.compose.runtime.Composable public static androidx.compose.foundation.text.input.TextFieldState rememberTextFieldState(optional String initialText, optional long initialSelection);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndPlaceCursorAtEnd(androidx.compose.foundation.text.input.TextFieldState, String text);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndSelectAll(androidx.compose.foundation.text.input.TextFieldState, String text);
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text.input.TextFieldCharSequence> textAsFlow(androidx.compose.foundation.text.input.TextFieldState);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text.input.TextFieldCharSequence> valueAsFlow(androidx.compose.foundation.text.input.TextFieldState);
   }
 
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @kotlin.jvm.JvmInline public final value class TextObfuscationMode {
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index 652aacc..86fc321 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -1752,10 +1752,14 @@
     method @kotlin.PublishedApi internal void commitEdit(androidx.compose.foundation.text.input.TextFieldBuffer newValue);
     method public inline void edit(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.text.input.TextFieldBuffer,kotlin.Unit> block);
     method @kotlin.PublishedApi internal void finishEditing();
-    method public androidx.compose.foundation.text.input.TextFieldCharSequence getText();
+    method public androidx.compose.ui.text.TextRange? getComposition();
+    method public long getSelection();
+    method public CharSequence getText();
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public androidx.compose.foundation.text.input.UndoState getUndoState();
-    method @kotlin.PublishedApi internal androidx.compose.foundation.text.input.TextFieldBuffer startEdit(androidx.compose.foundation.text.input.TextFieldCharSequence value);
-    property public final androidx.compose.foundation.text.input.TextFieldCharSequence text;
+    method @kotlin.PublishedApi internal androidx.compose.foundation.text.input.TextFieldBuffer startEdit();
+    property public final androidx.compose.ui.text.TextRange? composition;
+    property public final long selection;
+    property public final CharSequence text;
     property @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final androidx.compose.foundation.text.input.UndoState undoState;
   }
 
@@ -1767,11 +1771,10 @@
 
   public final class TextFieldStateKt {
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void clearText(androidx.compose.foundation.text.input.TextFieldState);
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static suspend Object? forEachTextValue(androidx.compose.foundation.text.input.TextFieldState, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.text.input.TextFieldCharSequence,? super kotlin.coroutines.Continuation<? super kotlin.Unit>,?> block, kotlin.coroutines.Continuation<?>);
     method @androidx.compose.runtime.Composable public static androidx.compose.foundation.text.input.TextFieldState rememberTextFieldState(optional String initialText, optional long initialSelection);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndPlaceCursorAtEnd(androidx.compose.foundation.text.input.TextFieldState, String text);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static void setTextAndSelectAll(androidx.compose.foundation.text.input.TextFieldState, String text);
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text.input.TextFieldCharSequence> textAsFlow(androidx.compose.foundation.text.input.TextFieldState);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static kotlinx.coroutines.flow.Flow<androidx.compose.foundation.text.input.TextFieldCharSequence> valueAsFlow(androidx.compose.foundation.text.input.TextFieldState);
   }
 
   @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @kotlin.jvm.JvmInline public final value class TextObfuscationMode {
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
index 2558c13..b3bcb53 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/text2/BasicTextFieldOutputTransformationDemos.kt
@@ -79,7 +79,7 @@
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun InsertReplaceDeleteDemo() {
-    val text = remember { TextFieldState("abc def ghi") }
+    val state = remember { TextFieldState("abc def ghi") }
     var prefixEnabled by remember { mutableStateOf(true) }
     var suffixEnabled by remember { mutableStateOf(true) }
     var middleWedge by remember { mutableStateOf(true) }
@@ -136,7 +136,7 @@
         }
         var isFirstFieldFocused by remember { mutableStateOf(false) }
         BasicTextField(
-            state = text,
+            state = state,
             onTextLayout = { textLayoutResultProvider = it },
             modifier = Modifier
                 .alignByBaseline()
@@ -149,7 +149,7 @@
                     // Only draw selection outline when not focused.
                     if (isFirstFieldFocused) return@drawWithContent
                     val textLayoutResult = textLayoutResultProvider() ?: return@drawWithContent
-                    val selection = text.text.selection
+                    val selection = state.selection
                     if (selection.collapsed) {
                         val cursorRect = textLayoutResult.getCursorRect(selection.start)
                         drawLine(
@@ -175,7 +175,7 @@
             modifier = Modifier.alignBy { (it.measuredHeight * 0.75f).toInt() }
         )
         BasicTextField(
-            state = text,
+            state = state,
             modifier = Modifier
                 .alignByBaseline()
                 .weight(0.5f)
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldSamples.kt
index baed998..2604fea 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldSamples.kt
@@ -44,12 +44,11 @@
 import androidx.compose.foundation.text.input.delete
 import androidx.compose.foundation.text.input.forEachChange
 import androidx.compose.foundation.text.input.forEachChangeReversed
-import androidx.compose.foundation.text.input.forEachTextValue
 import androidx.compose.foundation.text.input.insert
 import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
-import androidx.compose.foundation.text.input.textAsFlow
 import androidx.compose.foundation.text.input.then
+import androidx.compose.foundation.text.input.valueAsFlow
 import androidx.compose.material.Icon
 import androidx.compose.material.IconButton
 import androidx.compose.material.Text
@@ -224,7 +223,7 @@
 
         /** Called while the view model is active, e.g. from a LaunchedEffect. */
         suspend fun run() {
-            searchFieldState.forEachTextValue { queryText ->
+            searchFieldState.valueAsFlow().collectLatest { queryText ->
                 // Start a new search every time the user types something valid. If the previous
                 // search is still being processed when the text is changed, it will be cancelled
                 // and this code will run again with the latest query text.
@@ -426,41 +425,6 @@
     })
 }
 
-@Sampled
-fun BasicTextFieldForEachTextValueSample() {
-    class SearchViewModel {
-        val searchFieldState = TextFieldState()
-        var searchResults: List<String> by mutableStateOf(emptyList())
-            private set
-
-        /** Called while the view model is active, e.g. from a LaunchedEffect. */
-        suspend fun run() {
-            searchFieldState.forEachTextValue { queryText ->
-                // Start a new search every time the user types something. If the previous search
-                // is still being processed when the text is changed, it will be cancelled and this
-                // code will run again with the latest query text.
-                searchResults = performSearch(query = queryText)
-            }
-        }
-
-        private suspend fun performSearch(query: CharSequence): List<String> {
-            TODO()
-        }
-    }
-
-    @Composable
-    fun SearchScreen(viewModel: SearchViewModel) {
-        Column {
-            BasicTextField(viewModel.searchFieldState)
-            LazyColumn {
-                items(viewModel.searchResults) {
-                    TODO()
-                }
-            }
-        }
-    }
-}
-
 @OptIn(FlowPreview::class)
 @Suppress("RedundantSuspendModifier")
 @Sampled
@@ -472,7 +436,7 @@
 
         /** Called while the view model is active, e.g. from a LaunchedEffect. */
         suspend fun run() {
-            searchFieldState.textAsFlow()
+            searchFieldState.valueAsFlow()
                 // Let fast typers get multiple keystrokes in before kicking off a search.
                 .debounce(500)
                 // collectLatest cancels the previous search if it's still running when there's a
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldValueSample.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldValueSample.kt
index 14a7720..356546a 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldValueSample.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/BasicTextFieldValueSample.kt
@@ -19,7 +19,6 @@
 import androidx.annotation.Sampled
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.input.TextFieldCharSequence
 import androidx.compose.foundation.text.input.TextFieldState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -46,7 +45,6 @@
     )
 }
 
-@OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun StringTextField(
     value: String,
@@ -198,20 +196,19 @@
     }
 
     private fun observeTextState(fireOnValueChanged: Boolean = true) {
-        lateinit var text: TextFieldCharSequence
+        lateinit var value: TextFieldValue
         observeReads {
-            text = state.text
+            value = TextFieldValue(
+                state.text.toString(),
+                state.selection,
+                state.composition
+            )
         }
 
         // 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.selection,
-                composition = text.composition
-            )
-            onValueChanged(newValue)
+            onValueChanged(value)
         }
     }
 }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt
index 3ad3c19..4a2d653 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldSemanticsTest.kt
@@ -385,7 +385,7 @@
         rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(2, 3))
+            assertThat(state.selection).isEqualTo(TextRange(2, 3))
         }
     }
 
@@ -406,7 +406,7 @@
         rule.onNodeWithTag(Tag).performTextInputSelection(TextRange(2))
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
         }
     }
 
@@ -517,7 +517,7 @@
         rule.onNodeWithTag(Tag).performSemanticsAction(SemanticsActions.PasteText)
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
             assertThat(state.text.toString()).isEqualTo("Hello World!")
         }
     }
@@ -548,7 +548,7 @@
         rule.onNodeWithTag(Tag).performSemanticsAction(SemanticsActions.PasteText)
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(9))
+            assertThat(state.selection).isEqualTo(TextRange(9))
             assertThat(state.text.toString()).isEqualTo("Heo Word!")
         }
     }
@@ -611,7 +611,7 @@
         rule.onNodeWithTag(Tag).performSemanticsAction(SemanticsActions.CopyText)
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
         }
     }
 
@@ -633,7 +633,7 @@
 
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo(" World!")
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             assertThat(clipboardManager.getText()?.toString()).isEqualTo("Hello")
         }
     }
@@ -659,7 +659,7 @@
 
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo("Hello World!")
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(clipboardManager.getText()?.toString()).isEqualTo("Hello")
         }
     }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
index f5952d3..6e216ca 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/BasicTextFieldTest.kt
@@ -1134,7 +1134,7 @@
         rule.waitForIdle()
 
         assertThat(tfs.text.toString()).isEqualTo(longText)
-        assertThat(tfs.text.selection).isEqualTo(TextRange(longText.length))
+        assertThat(tfs.selection).isEqualTo(TextRange(longText.length))
     }
 
     @Test
@@ -1155,7 +1155,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(imm.expectCall("updateSelection(0, 5, -1, -1)"))
         }
     }
@@ -1230,7 +1230,7 @@
 
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo("Worldo")
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/DecorationBoxTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/DecorationBoxTest.kt
index 4944195..96cb15d 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/DecorationBoxTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/DecorationBoxTest.kt
@@ -273,7 +273,7 @@
 
         // assertThat selection happened
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
         }
     }
 }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/RememberTextFieldStateTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/RememberTextFieldStateTest.kt
index bcadfb2..2fe532a 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/RememberTextFieldStateTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/RememberTextFieldStateTest.kt
@@ -50,7 +50,7 @@
 
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo("hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
         }
     }
 
@@ -78,7 +78,7 @@
 
         rule.runOnIdle {
             assertThat(restoredState.text.toString()).isEqualTo("hello, world")
-            assertThat(restoredState.text.selection).isEqualTo(TextRange(0, 12))
+            assertThat(restoredState.selection).isEqualTo(TextRange(0, 12))
         }
     }
 
@@ -109,7 +109,7 @@
 
         rule.runOnIdle {
             assertThat(restoredState.text.toString()).isEqualTo("hello, world")
-            assertThat(restoredState.text.selection).isEqualTo(TextRange(0, 12))
+            assertThat(restoredState.selection).isEqualTo(TextRange(0, 12))
         }
     }
 }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCodepointTransformationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCodepointTransformationTest.kt
index 36aa45b..bc7f1d1 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCodepointTransformationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCodepointTransformationTest.kt
@@ -437,7 +437,7 @@
         listOf(0, 1, 3, 4).forEachIndexed { i, expectedCursor ->
             rule.runOnIdle {
                 assertWithMessage("After pressing right arrow $i times")
-                    .that(state.text.selection).isEqualTo(TextRange(expectedCursor))
+                    .that(state.selection).isEqualTo(TextRange(expectedCursor))
             }
             rule.onNodeWithTag(Tag).performKeyInput {
                 pressKey(Key.DirectionRight)
@@ -477,7 +477,7 @@
         ).forEachIndexed { i, expectedSelection ->
             rule.runOnIdle {
                 assertWithMessage("After pressing shift+right arrow $i times")
-                    .that(state.text.selection).isEqualTo(expectedSelection)
+                    .that(state.selection).isEqualTo(expectedSelection)
             }
             rule.onNodeWithTag(Tag).performKeyInput {
                 withKeyDown(Key.ShiftLeft) {
@@ -519,7 +519,7 @@
         ).forEachIndexed { i, expectedSelection ->
             rule.runOnIdle {
                 assertWithMessage("After pressing shift+left arrow $i times")
-                    .that(state.text.selection).isEqualTo(expectedSelection)
+                    .that(state.selection).isEqualTo(expectedSelection)
             }
             rule.onNodeWithTag(Tag).performKeyInput {
                 withKeyDown(Key.ShiftLeft) {
@@ -815,7 +815,7 @@
                     .that(performSelectionOnVisualText(write)).isTrue()
                 rule.runOnIdle {
                     assertWithMessage("Visual selection $write to mapped")
-                        .that(text.selection).isEqualTo(expected)
+                        .that(selection).isEqualTo(expected)
                 }
             }
         }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCursorTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCursorTest.kt
index e062e6e..a982889 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCursorTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldCursorTest.kt
@@ -125,7 +125,7 @@
     private var textLayoutResult: (() -> TextLayoutResult?)? = null
     private val cursorRect: Rect
         // assume selection is collapsed
-        get() = textLayoutResult?.invoke()?.getCursorRect(state.text.selection.start)
+        get() = textLayoutResult?.invoke()?.getCursorRect(state.selection.start)
             ?: Rect.Zero
 
     private val cursorSize: DpSize by lazy {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldDragAndDropTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldDragAndDropTest.kt
index 90f6621..ff0558e 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldDragAndDropTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldDragAndDropTest.kt
@@ -78,9 +78,9 @@
     @Test
     fun nonTextContent_isNotAccepted() {
         rule.setContentAndTestDragAndDrop {
-            val startSelection = state.text.selection
+            val startSelection = state.selection
             drag(Offset(fontSize.toPx() * 2, 10f), defaultUri)
-            assertThat(state.text.selection).isEqualTo(startSelection)
+            assertThat(state.selection).isEqualTo(startSelection)
         }
     }
 
@@ -93,7 +93,7 @@
         ) {
             val accepted = drag(Offset(fontSize.toPx() * 2, 10f), defaultUri)
             assertThat(accepted).isTrue()
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
         }
     }
 
@@ -101,7 +101,7 @@
     fun textContent_isAccepted() {
         rule.setContentAndTestDragAndDrop {
             drag(Offset(fontSize.toPx() * 2, 10f), "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
         }
     }
 
@@ -109,11 +109,11 @@
     fun draggingText_updatesSelection() {
         rule.setContentAndTestDragAndDrop {
             drag(Offset(fontSize.toPx() * 1, 10f), "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(1))
+            assertThat(state.selection).isEqualTo(TextRange(1))
             drag(Offset(fontSize.toPx() * 2, 10f), "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
             drag(Offset(fontSize.toPx() * 3, 10f), "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(3))
+            assertThat(state.selection).isEqualTo(TextRange(3))
         }
     }
 
@@ -125,11 +125,11 @@
             }
         ) {
             drag(Offset(fontSize.toPx() * 1, 10f), defaultUri)
-            assertThat(state.text.selection).isEqualTo(TextRange(1))
+            assertThat(state.selection).isEqualTo(TextRange(1))
             drag(Offset(fontSize.toPx() * 2, 10f), defaultUri)
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
             drag(Offset(fontSize.toPx() * 3, 10f), defaultUri)
-            assertThat(state.text.selection).isEqualTo(TextRange(3))
+            assertThat(state.selection).isEqualTo(TextRange(3))
         }
     }
 
@@ -140,9 +140,9 @@
             modifier = Modifier.width(300.dp)
         ) {
             drag(Offset.Zero, "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             drag(Offset(295.dp.toPx(), 10f), "hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(4))
+            assertThat(state.selection).isEqualTo(TextRange(4))
         }
     }
 
@@ -354,7 +354,7 @@
                 " Awesome"
             )
             drop()
-            assertThat(state.text.selection).isEqualTo(TextRange("Hello Awesome".length))
+            assertThat(state.selection).isEqualTo(TextRange("Hello Awesome".length))
             assertThat(state.text.toString()).isEqualTo("Hello Awesome World!")
         }
     }
@@ -378,7 +378,7 @@
             }
             drag(Offset(fontSize.toPx() * 5, 10f), clipData)
             drop()
-            assertThat(state.text.selection).isEqualTo(TextRange("Hello Awesome".length))
+            assertThat(state.selection).isEqualTo(TextRange("Hello Awesome".length))
             assertThat(state.text.toString()).isEqualTo("Hello Awesome World!")
             assertThat(receivedContent.clipEntry.clipData.itemCount).isEqualTo(2)
             assertThat(receivedContent.clipEntry.firstUriOrNull()).isEqualTo(defaultUri)
@@ -402,7 +402,7 @@
             }
             drag(Offset(fontSize.toPx() * 5, 10f), clipData)
             drop()
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
             assertThat(state.text.toString()).isEqualTo("Hello World!")
             assertThat(receivedContent.clipEntry.clipData.itemCount).isEqualTo(2)
             assertThat(receivedContent.clipEntry.firstUriOrNull()).isEqualTo(defaultUri)
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
index bafdbfa..2e16280 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldKeyEventTest.kt
@@ -689,7 +689,7 @@
 
         fun expectedSelection(selection: TextRange) {
             rule.runOnIdle {
-                assertThat(state.text.selection).isEqualTo(selection)
+                assertThat(state.selection).isEqualTo(selection)
             }
         }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationGesturesIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationGesturesIntegrationTest.kt
index 599b9bf..7787cf8 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationGesturesIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationGesturesIntegrationTest.kt
@@ -77,7 +77,7 @@
             click(center + Offset(1f, 0f))
         }
         rule.runOnIdle {
-            assertThat(text.text.selection).isEqualTo(TextRange(2))
+            assertThat(text.selection).isEqualTo(TextRange(2))
         }
 
         rule.onNodeWithTag(Tag).performTouchInput {
@@ -87,7 +87,7 @@
             click(center + Offset(-1f, 0f))
         }
         rule.runOnIdle {
-            assertThat(text.text.selection).isEqualTo(TextRange(1))
+            assertThat(text.selection).isEqualTo(TextRange(1))
         }
     }
 
@@ -124,7 +124,7 @@
             click(topRight)
         }
         rule.runOnIdle {
-            assertThat(text.text.selection).isEqualTo(TextRange(indexOfA))
+            assertThat(text.selection).isEqualTo(TextRange(indexOfA))
         }
         assertCursor(indexOfA)
 
@@ -134,7 +134,7 @@
             click(bottomLeft)
         }
         rule.runOnIdle {
-            assertThat(text.text.selection)
+            assertThat(text.selection)
                 .isEqualTo(TextRange(indexOfA + 1))
         }
         assertCursor(indexOfA + replacement.length)
@@ -164,7 +164,7 @@
             click(center + Offset(1f, 0f))
         }
         rule.runOnIdle {
-            assertThat(text.text.selection).isEqualTo(TextRange(1))
+            assertThat(text.selection).isEqualTo(TextRange(1))
         }
         assertCursor(5)
 
@@ -175,7 +175,7 @@
             click(center + Offset(-1f, 0f))
         }
         rule.runOnIdle {
-            assertThat(text.text.selection).isEqualTo(TextRange(1))
+            assertThat(text.selection).isEqualTo(TextRange(1))
         }
         assertCursor(1)
     }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt
index 768fd70..c10b343 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldOutputTransformationHardwareKeysIntegrationTest.kt
@@ -78,13 +78,13 @@
         }
         rule.onNodeWithTag(Tag).requestFocus()
 
-        assertThat(text.text.selection).isEqualTo(TextRange(0))
+        assertThat(text.selection).isEqualTo(TextRange(0))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(3))
+        assertThat(text.selection).isEqualTo(TextRange(3))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(4))
+        assertThat(text.selection).isEqualTo(TextRange(4))
     }
 
     @Test
@@ -100,13 +100,13 @@
         }
         rule.onNodeWithTag(Tag).requestFocus()
 
-        assertThat(text.text.selection).isEqualTo(TextRange(4))
+        assertThat(text.selection).isEqualTo(TextRange(4))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(3))
+        assertThat(text.selection).isEqualTo(TextRange(3))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(0))
+        assertThat(text.selection).isEqualTo(TextRange(0))
     }
 
     @Test
@@ -181,13 +181,13 @@
         }
         rule.onNodeWithTag(Tag).requestFocus()
 
-        assertThat(text.text.selection).isEqualTo(TextRange(0))
+        assertThat(text.selection).isEqualTo(TextRange(0))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionRight)
-        assertThat(text.text.selection).isEqualTo(TextRange(2))
+        assertThat(text.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -203,13 +203,13 @@
         }
         rule.onNodeWithTag(Tag).requestFocus()
 
-        assertThat(text.text.selection).isEqualTo(TextRange(2))
+        assertThat(text.selection).isEqualTo(TextRange(2))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(1))
+        assertThat(text.selection).isEqualTo(TextRange(1))
         pressKey(Key.DirectionLeft)
-        assertThat(text.text.selection).isEqualTo(TextRange(0))
+        assertThat(text.selection).isEqualTo(TextRange(0))
     }
 
     @Test
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldScrollTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldScrollTest.kt
index 8781298..6a566ee 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldScrollTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/TextFieldScrollTest.kt
@@ -458,7 +458,7 @@
 
         rule.runOnIdle {
             assertThat(scrollState.value).isEqualTo(scrollState.maxValue)
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
         }
     }
 
@@ -591,9 +591,9 @@
         rule.onNodeWithTag("field").assertTextEquals("aaaaaaaaaa")
         rule.waitUntil(
             "scrollState.value (${scrollState.value}) == 0 && " +
-                "state.text.selection (${state.text.selection}) == TextRange(0)"
+                "state.selection (${state.selection}) == TextRange(0)"
         ) {
-            scrollState.value == 0 && state.text.selection == TextRange(0)
+            scrollState.value == 0 && state.selection == TextRange(0)
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt
index 73e32c2..f944f79 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/TextInputServiceAndroidCursorAnchorInfoTest.kt
@@ -125,8 +125,8 @@
         // Immediate update.
         val expectedInfo = builder.build(
             text = textFieldState.text,
-            selection = textFieldState.text.selection,
-            composition = textFieldState.text.composition,
+            selection = textFieldState.selection,
+            composition = textFieldState.composition,
             textLayoutResult = layoutState.layoutResult!!,
             matrix = getAndroidMatrix(windowOffset),
             innerTextFieldBounds = Rect.Zero,
@@ -186,8 +186,8 @@
         // Monitoring update.
         val expectedInfo = builder.build(
             text = textFieldState.text,
-            selection = textFieldState.text.selection,
-            composition = textFieldState.text.composition,
+            selection = textFieldState.selection,
+            composition = textFieldState.composition,
             textLayoutResult = layoutState.layoutResult!!,
             matrix = getAndroidMatrix(Offset(67f, 89f)),
             innerTextFieldBounds = Rect.Zero,
@@ -208,8 +208,8 @@
         // Immediate update.
         val expectedInfo = builder.build(
             text = textFieldState.text,
-            selection = textFieldState.text.selection,
-            composition = textFieldState.text.composition,
+            selection = textFieldState.selection,
+            composition = textFieldState.composition,
             textLayoutResult = layoutState.layoutResult!!,
             matrix = getAndroidMatrix(windowOffset),
             innerTextFieldBounds = Rect.Zero,
@@ -225,8 +225,8 @@
         // Monitoring update.
         val expectedInfo2 = builder.build(
             text = textFieldState.text,
-            selection = textFieldState.text.selection,
-            composition = textFieldState.text.composition,
+            selection = textFieldState.selection,
+            composition = textFieldState.composition,
             textLayoutResult = layoutState.layoutResult!!,
             matrix = getAndroidMatrix(Offset(67f, 89f)),
             innerTextFieldBounds = Rect.Zero,
@@ -247,8 +247,8 @@
         // Immediate update.
         val expectedInfo = builder.build(
             text = textFieldState.text,
-            selection = textFieldState.text.selection,
-            composition = textFieldState.text.composition,
+            selection = textFieldState.selection,
+            composition = textFieldState.composition,
             textLayoutResult = layoutState.layoutResult!!,
             matrix = getAndroidMatrix(windowOffset),
             innerTextFieldBounds = Rect.Zero,
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldClickToMoveCursorTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldClickToMoveCursorTest.kt
index d848cac..687eb4c 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldClickToMoveCursorTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldClickToMoveCursorTest.kt
@@ -82,15 +82,15 @@
 
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(center) }
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             performTouchInput { click(Offset(left + 1f, top + 1f)) } // topLeft
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             performTouchInput { click(Offset(right - 1f, top + 1f)) } // topRight
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             performTouchInput { click(Offset(left + 1f, bottom - 1f)) } // bottomLeft
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
             performTouchInput { click(Offset(right - 1f, bottom - 1f)) } // bottomRight
-            assertThat(state.text.selection).isEqualTo(TextRange(0))
+            assertThat(state.selection).isEqualTo(TextRange(0))
         }
     }
 
@@ -111,7 +111,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset((fontSize * 2).toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -133,7 +133,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset(right - (fontSize * 2).toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -155,7 +155,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset(right - (fontSize * 2).toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
     }
 
     @Test
@@ -175,7 +175,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset((fontSize * 2).toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
     }
 
     @Test
@@ -195,7 +195,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset((fontSize * 4).toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
     }
 
     @Test
@@ -217,7 +217,7 @@
         with(rule.onNodeWithTag(TAG)) {
             performTouchInput { click(Offset(fontSize.toPx(), height / 2f)) }
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
     }
 
     @Test
@@ -242,7 +242,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(6))
+            assertThat(state.selection).isEqualTo(TextRange(6))
             assertThat(scrollState.value).isGreaterThan(0)
         }
     }
@@ -268,7 +268,7 @@
             performTouchInput { click(Offset(fontSize.toPx(), bottom - 1f)) }
         }
         rule.waitForIdle()
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
         assertThat(scrollState.value).isGreaterThan(0)
     }
 
@@ -300,6 +300,6 @@
             performTouchInput { click(Offset(2 * fontSize.toPx(), centerY)) }
         }
         rule.waitForIdle()
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt
index 728e321..8b210f4 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldCursorHandleTest.kt
@@ -102,7 +102,7 @@
             click(Offset(fontSize.toPx() * 2, fontSize.toPx() / 2))
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
 
         rule.onNode(isSelectionHandle(Handle.Cursor)).assertHandlePositionMatches(
             (2 * fontSize.value).dp + cursorWidth / 2,
@@ -155,7 +155,7 @@
             click(Offset(fontSize.toPx() * 2, fontSize.toPx() / 2))
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4))
+        assertThat(state.selection).isEqualTo(TextRange(4))
     }
 
     @Test
@@ -177,7 +177,7 @@
             click(Offset(fontSize.toPx() * 8, fontSize.toPx() / 2))
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
 
         rule.onNode(isSelectionHandle(Handle.Cursor)).assertHandlePositionMatches(
             5 * fontSizeDp + cursorWidth / 2,
@@ -206,7 +206,7 @@
             click(Offset(fontSize.toPx() * 2, fontSize.toPx() / 2))
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
 
         rule.onNode(isSelectionHandle(Handle.Cursor)).assertHandlePositionMatches(
             (2 * fontSize.value).dp + cursorWidth / 2,
@@ -233,7 +233,7 @@
             click(Offset(fontSize.toPx() * 8, fontSize.toPx() / 2))
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
 
         rule.onNode(isSelectionHandle(Handle.Cursor)).assertHandlePositionMatches(
             (5 * fontSize.value).dp + cursorWidth / 2,
@@ -604,7 +604,7 @@
         swipeToRight(fontSizePx * 5)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange.Zero)
+        assertThat(state.selection).isEqualTo(TextRange.Zero)
     }
 
     // region ltr drag tests
@@ -629,7 +629,7 @@
         swipeToRight(fontSizePx)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
     }
 
     @Test
@@ -653,7 +653,7 @@
         swipeToLeft(fontSizePx)
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(2))
+            assertThat(state.selection).isEqualTo(TextRange(2))
         }
     }
 
@@ -678,7 +678,7 @@
         swipeToRight(getTextFieldWidth() * 2)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
     }
 
     @Test
@@ -702,7 +702,7 @@
         swipeToLeft(getTextFieldWidth() * 2)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange.Zero)
+        assertThat(state.selection).isEqualTo(TextRange.Zero)
     }
 
     @Test
@@ -730,7 +730,7 @@
         swipeToRight(getTextFieldWidth() * 3)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(state.text.length))
+        assertThat(state.selection).isEqualTo(TextRange(state.text.length))
     }
 
     @Test
@@ -757,12 +757,12 @@
 
         swipeToRight(fontSizePx * 12, durationMillis = 1)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(12))
+            assertThat(state.selection).isEqualTo(TextRange(12))
         }
 
         swipeToRight(fontSizePx * 2, durationMillis = 1)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(14))
+            assertThat(state.selection).isEqualTo(TextRange(14))
         }
     }
 
@@ -792,7 +792,7 @@
         swipeToRight(fontSizePx)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -818,7 +818,7 @@
         swipeToLeft(fontSizePx)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
     }
 
     @Test
@@ -844,7 +844,7 @@
         swipeToRight(getTextFieldWidth() * 2)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange.Zero)
+        assertThat(state.selection).isEqualTo(TextRange.Zero)
     }
 
     @Test
@@ -870,7 +870,7 @@
         swipeToLeft(getTextFieldWidth() * 2)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(state.text.length))
+        assertThat(state.selection).isEqualTo(TextRange(state.text.length))
     }
 
     @Test
@@ -902,7 +902,7 @@
         swipeToLeft(getTextFieldWidth() * 3)
         rule.waitForIdle()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(state.text.length))
+        assertThat(state.selection).isEqualTo(TextRange(state.text.length))
     }
 
     @Test
@@ -936,12 +936,12 @@
 
         swipeToLeft(fontSizePx * 12, durationMillis = 1)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(12))
+            assertThat(state.selection).isEqualTo(TextRange(12))
         }
 
         swipeToLeft(fontSizePx * 2, durationMillis = 1)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(14))
+            assertThat(state.selection).isEqualTo(TextRange(14))
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt
index 5d0f3ce..509edf6 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldLongPressTest.kt
@@ -129,7 +129,7 @@
         }
 
         rule.onNode(isSelectionHandle(Handle.Cursor)).assertIsDisplayed()
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
     }
 
     @Test
@@ -183,7 +183,7 @@
 
         rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
         rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 7))
+        assertThat(state.selection).isEqualTo(TextRange(4, 7))
     }
 
     @Test
@@ -203,8 +203,8 @@
 
         rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
         rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selection).isNotEqualTo(TextRange(7, 8))
-        assertThat(state.text.selection.collapsed).isFalse()
+        assertThat(state.selection).isNotEqualTo(TextRange(7, 8))
+        assertThat(state.selection.collapsed).isFalse()
     }
 
     @Test
@@ -232,7 +232,7 @@
 
         rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
         rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selection).isEqualTo(TextRange(20, 23))
+        assertThat(state.selection).isEqualTo(TextRange(20, 23))
     }
 
     @Test
@@ -262,7 +262,7 @@
 
         rule.onNode(isSelectionHandle(Handle.SelectionStart)).assertIsDisplayed()
         rule.onNode(isSelectionHandle(Handle.SelectionEnd)).assertIsDisplayed()
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 7))
+        assertThat(state.selection).isEqualTo(TextRange(4, 7))
     }
 
     @Test
@@ -282,7 +282,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 11))
+        assertThat(state.selection).isEqualTo(TextRange(4, 11))
     }
 
     @Test
@@ -302,7 +302,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 7))
+        assertThat(state.selection).isEqualTo(TextRange(0, 7))
     }
 
     @Test
@@ -322,7 +322,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 15))
+        assertThat(state.selection).isEqualTo(TextRange(4, 15))
     }
 
     @Test
@@ -342,7 +342,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 15))
+        assertThat(state.selection).isEqualTo(TextRange(4, 15))
     }
 
     @Test
@@ -364,7 +364,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 7))
+        assertThat(state.selection).isEqualTo(TextRange(4, 7))
     }
 
     //region RTL
@@ -386,7 +386,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 7))
+        assertThat(state.selection).isEqualTo(TextRange(0, 7))
     }
 
     @Test
@@ -406,7 +406,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 11))
+        assertThat(state.selection).isEqualTo(TextRange(4, 11))
     }
 
     @Test
@@ -426,7 +426,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 11))
+        assertThat(state.selection).isEqualTo(TextRange(0, 11))
     }
 
     @Test
@@ -446,7 +446,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 11))
+        assertThat(state.selection).isEqualTo(TextRange(0, 11))
     }
 
     @Test
@@ -470,7 +470,7 @@
             up()
         }
 
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 7))
+        assertThat(state.selection).isEqualTo(TextRange(4, 7))
     }
 
     @Test
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionHandlesTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionHandlesTest.kt
index 43f45e8..466b0a6 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionHandlesTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionHandlesTest.kt
@@ -335,13 +335,13 @@
         rule.onNodeWithTag(TAG).performTouchInput { swipeLeft() }
         assertHandlesNotExist()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
 
         rule.onNodeWithTag(TAG).performTouchInput { swipeRight() }
         assertHandlesDisplayed()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
     }
 
@@ -368,7 +368,7 @@
         }
         assertHandlesNotExist()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
 
         rule.onNodeWithTag(TAG).performTouchInput {
@@ -376,7 +376,7 @@
         }
         assertHandlesDisplayed()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
     }
 
@@ -412,7 +412,7 @@
         }
         assertHandlesNotExist()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
 
         rule.onNodeWithTag(containerTag).performTouchInput {
@@ -420,7 +420,7 @@
         }
         assertHandlesDisplayed()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
     }
 
@@ -456,7 +456,7 @@
         }
         assertHandlesNotExist()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
 
         rule.onNodeWithTag(containerTag).performTouchInput {
@@ -464,7 +464,7 @@
         }
         assertHandlesDisplayed()
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(1, 2))
+            assertThat(state.selection).isEqualTo(TextRange(1, 2))
         }
     }
 
@@ -485,7 +485,7 @@
 
         swipeToLeft(Handle.SelectionStart, fontSizePx * 4)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 7))
+            assertThat(state.selection).isEqualTo(TextRange(0, 7))
         }
     }
 
@@ -506,7 +506,7 @@
 
         swipeToRight(Handle.SelectionEnd, fontSizePx * 4)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(4, 11))
+            assertThat(state.selection).isEqualTo(TextRange(4, 11))
         }
     }
 
@@ -529,7 +529,7 @@
             doubleClick(Offset(fontSizePx * 5, fontSizePx / 2)) // middle word
         }
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(4, 7))
+            assertThat(state.selection).isEqualTo(TextRange(4, 7))
         }
     }
 
@@ -554,8 +554,8 @@
             doubleClick(Offset(fontSizePx * 3.5f, fontSizePx / 2))
         }
         rule.runOnIdle {
-            assertThat(state.text.selection).isNotEqualTo(TextRange(3, 4))
-            assertThat(state.text.selection.collapsed).isFalse()
+            assertThat(state.selection).isNotEqualTo(TextRange(3, 4))
+            assertThat(state.selection.collapsed).isFalse()
         }
     }
 
@@ -585,7 +585,7 @@
             swipeToLeft(Handle.SelectionStart, fontSizePx)
         }
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 80))
+            assertThat(state.selection).isEqualTo(TextRange(0, 80))
         }
     }
 
@@ -615,7 +615,7 @@
         // make sure that we also swipe to start on the first line
         swipeToLeft(Handle.SelectionStart, fontSizePx * 10)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 80))
+            assertThat(state.selection).isEqualTo(TextRange(0, 80))
         }
     }
 
@@ -640,7 +640,7 @@
             swipeToRight(Handle.SelectionEnd, fontSizePx)
         }
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 80))
+            assertThat(state.selection).isEqualTo(TextRange(0, 80))
         }
     }
 
@@ -669,7 +669,7 @@
             swipeToRight(Handle.SelectionEnd, layoutResult.size.width.toFloat())
         }
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 80))
+            assertThat(state.selection).isEqualTo(TextRange(0, 80))
         }
     }
 
@@ -691,7 +691,7 @@
         swipeToLeft(Handle.SelectionStart, fontSizePx * 2) // only move by 2 characters
         rule.runOnIdle {
             // selection extends by a word
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 7))
+            assertThat(state.selection).isEqualTo(TextRange(0, 7))
         }
     }
 
@@ -713,7 +713,7 @@
         swipeToRight(Handle.SelectionEnd, fontSizePx * 2) // only move by 2 characters
         rule.runOnIdle {
             // selection extends by a word
-            assertThat(state.text.selection).isEqualTo(TextRange(4, 11))
+            assertThat(state.selection).isEqualTo(TextRange(4, 11))
         }
     }
 
@@ -735,7 +735,7 @@
         swipeToRight(Handle.SelectionStart, fontSizePx) // only move by a single character
         rule.runOnIdle {
             // selection shrinks by a character
-            assertThat(state.text.selection).isEqualTo(TextRange(5, 7))
+            assertThat(state.selection).isEqualTo(TextRange(5, 7))
         }
     }
 
@@ -757,7 +757,7 @@
         swipeToLeft(Handle.SelectionEnd, fontSizePx) // only move by a single character
         rule.runOnIdle {
             // selection shrinks by a character
-            assertThat(state.text.selection).isEqualTo(TextRange(4, 6))
+            assertThat(state.selection).isEqualTo(TextRange(4, 6))
         }
     }
 
@@ -778,7 +778,7 @@
 
         swipeToRight(Handle.SelectionStart, fontSizePx * 7)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(11, 7))
+            assertThat(state.selection).isEqualTo(TextRange(11, 7))
         }
     }
 
@@ -799,7 +799,7 @@
 
         swipeToLeft(Handle.SelectionEnd, fontSizePx * 7)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(4, 0))
+            assertThat(state.selection).isEqualTo(TextRange(4, 0))
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionOnBackTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionOnBackTest.kt
index 25801cc..c7d8baa 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionOnBackTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionOnBackTest.kt
@@ -78,7 +78,7 @@
         textNode.performKeyInput { pressKey(Key.Back) }
         val expected = TextRange(3, 3)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(expected)
+            assertThat(state.selection).isEqualTo(expected)
         }
     }
 
@@ -106,7 +106,7 @@
         textNode.performKeyInput { pressKey(Key.Back) }
         val expected = TextRange(3, 3)
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(expected)
+            assertThat(state.selection).isEqualTo(expected)
             assertThat(backPressed).isEqualTo(0)
         }
     }
@@ -129,7 +129,7 @@
         // should have no effect
         textNode.performKeyInput { keyDown(Key.Back) }
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(expected)
+            assertThat(state.selection).isEqualTo(expected)
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt
index 0106d69..2577a43 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldTextToolbarTest.kt
@@ -193,7 +193,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(5, 2))
+            assertThat(state.selection).isEqualTo(TextRange(5, 2))
             assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Hidden)
         }
 
@@ -206,7 +206,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Hidden)
         }
     }
@@ -226,7 +226,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Hidden)
         }
     }
@@ -243,7 +243,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Hidden)
         }
     }
@@ -262,7 +262,7 @@
         }
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+            assertThat(state.selection).isEqualTo(TextRange(0, 5))
             assertThat(textToolbar.status).isEqualTo(TextToolbarStatus.Shown)
         }
     }
@@ -494,7 +494,7 @@
 
         selectAllOption?.invoke()
 
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+        assertThat(state.selection).isEqualTo(TextRange(0, 5))
         rule.runOnIdle {
             assertThat(selectAllOption).isNull()
         }
@@ -615,7 +615,7 @@
 
         rule.runOnIdle {
             assertThat(state.text.toString()).isEqualTo("Heworldllo")
-            assertThat(state.text.selection).isEqualTo(TextRange(7))
+            assertThat(state.selection).isEqualTo(TextRange(7))
         }
     }
 
@@ -688,7 +688,7 @@
 
         rule.runOnIdle {
             assertThat(clipboardManager.getText()?.toString()).isEqualTo("Hello")
-            assertThat(state.text.selection).isEqualTo(TextRange(5))
+            assertThat(state.selection).isEqualTo(TextRange(5))
         }
     }
 
@@ -715,7 +715,7 @@
         rule.runOnIdle {
             assertThat(clipboardManager.getText()?.toString()).isEqualTo("ello")
             assertThat(state.text.toString()).isEqualTo("H World!")
-            assertThat(state.text.selection).isEqualTo(TextRange(1))
+            assertThat(state.selection).isEqualTo(TextRange(1))
         }
     }
 
@@ -747,7 +747,7 @@
         rule.runOnIdle {
             assertThat(clipboardManager.getText()?.toString()).isEqualTo("ello")
             assertThat(state.text.toString()).isEqualTo("Hello World!")
-            assertThat(state.text.selection).isEqualTo(TextRange(1))
+            assertThat(state.selection).isEqualTo(TextRange(1))
         }
     }
 
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/gesture/TextFieldScrolledSelectionGestureTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/gesture/TextFieldScrolledSelectionGestureTest.kt
index cca04f5..7ac3f35 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/gesture/TextFieldScrolledSelectionGestureTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/selection/gesture/TextFieldScrolledSelectionGestureTest.kt
@@ -143,7 +143,7 @@
 
         fun assertSelectionEquals(selectionRange: Pair<Int, Int>) {
             val (start, end) = selectionRange
-            assertThat(textFieldState.text.selection).isEqualTo(TextRange(start, end))
+            assertThat(textFieldState.selection).isEqualTo(TextRange(start, end))
         }
 
         fun assertNoMagnifierExists() {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/undo/BasicTextFieldUndoTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/undo/BasicTextFieldUndoTest.kt
index e0051fd..901345d 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/undo/BasicTextFieldUndoTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/undo/BasicTextFieldUndoTest.kt
@@ -212,7 +212,7 @@
         state.undoState.undo()
 
         rule.runOnIdle {
-            assertThat(state.text.selection).isNotEqualTo(TextRange(7))
+            assertThat(state.selection).isNotEqualTo(TextRange(7))
         }
 
         state.undoState.redo()
@@ -368,7 +368,7 @@
     private fun TextFieldState.assertTextAndSelection(text: String, selection: TextRange) {
         rule.runOnIdle {
             assertThat(this.text.toString()).isEqualTo(text)
-            assertThat(this.text.selection).isEqualTo(selection)
+            assertThat(this.selection).isEqualTo(selection)
         }
     }
 }
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt
index 6dbf160..9437e55 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateSaverTest.kt
@@ -37,7 +37,7 @@
 
         assertNotNull(restoredState)
         assertThat(restoredState.text.toString()).isEqualTo("hello, world")
-        assertThat(restoredState.text.selection).isEqualTo(TextRange(0, 5))
+        assertThat(restoredState.selection).isEqualTo(TextRange(0, 5))
     }
 
     @Test
@@ -57,7 +57,7 @@
         assertThat(restoredState.undoState.canUndo).isTrue()
         restoredState.undoState.undo()
         assertThat(restoredState.text.toString()).isEqualTo("hello, world")
-        assertThat(restoredState.text.selection).isEqualTo(TextRange(0, 5))
+        assertThat(restoredState.selection).isEqualTo(TextRange(0, 5))
     }
 
     private object TestSaverScope : SaverScope {
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt
index 5aab181..4afc7a0 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/TextFieldStateTest.kt
@@ -17,6 +17,7 @@
 package androidx.compose.foundation.text.input
 
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.runtime.snapshots.SnapshotStateObserver
 import androidx.compose.ui.text.TextRange
@@ -27,6 +28,7 @@
 import kotlinx.coroutines.awaitCancellation
 import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.job
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.TestScope
@@ -47,21 +49,21 @@
     fun defaultInitialTextAndSelection() {
         val state = TextFieldState()
         assertThat(state.text.toString()).isEqualTo("")
-        assertThat(state.text.selection).isEqualTo(TextRange.Zero)
+        assertThat(state.selection).isEqualTo(TextRange.Zero)
     }
 
     @Test
     fun customInitialTextAndDefaultSelection() {
         val state = TextFieldState(initialText = "hello")
         assertThat(state.text.toString()).isEqualTo("hello")
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
     }
 
     @Test
     fun customInitialTextAndSelection() {
         val state = TextFieldState(initialText = "hello", initialSelection = TextRange(0, 1))
         assertThat(state.text.toString()).isEqualTo("hello")
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 1))
+        assertThat(state.selection).isEqualTo(TextRange(0, 1))
     }
 
     @Test
@@ -290,7 +292,7 @@
             replace(0, 0, "hello")
             placeCursorAtEnd()
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
     }
 
     @Test
@@ -299,7 +301,7 @@
             replace(0, 0, "hello")
             placeCursorBeforeCharAt(2)
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -321,7 +323,7 @@
             replace(0, 0, "hello")
             selectAll()
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+        assertThat(state.selection).isEqualTo(TextRange(0, 5))
     }
 
     @Test
@@ -330,7 +332,7 @@
             replace(0, 0, "hello")
             selection = TextRange(1, 4)
         }
-        assertThat(state.text.selection).isEqualTo(TextRange(1, 4))
+        assertThat(state.selection).isEqualTo(TextRange(1, 4))
     }
 
     @Test
@@ -398,14 +400,7 @@
     fun setTextAndPlaceCursorAtEnd_works() {
         state.setTextAndPlaceCursorAtEnd("Hello")
         assertThat(state.text.toString()).isEqualTo("Hello")
-        assertThat(state.text.selection).isEqualTo(TextRange(5))
-    }
-
-    @Test
-    fun setTextAndSelectAll_works() {
-        state.setTextAndSelectAll("Hello")
-        assertThat(state.text.toString()).isEqualTo("Hello")
-        assertThat(state.text.selection).isEqualTo(TextRange(0, 5))
+        assertThat(state.selection).isEqualTo(TextRange(5))
     }
 
     @Test
@@ -466,11 +461,11 @@
         val texts = mutableListOf<TextFieldCharSequence>()
 
         launch(Dispatchers.Unconfined) {
-            state.forEachTextValue { texts += it }
+            state.valueAsFlow().collectLatest { texts += it }
         }
 
         assertThat(texts).hasSize(1)
-        assertThat(texts.single()).isSameInstanceAs(state.text)
+        assertThat(texts.single()).isSameInstanceAs(state.value)
         assertThat(texts.single().toString()).isEqualTo("hello")
         assertThat(texts.single().selection).isEqualTo(TextRange(5))
     }
@@ -479,10 +474,10 @@
     fun forEachValue_fires_whenTextChanged() = runTestWithSnapshotsThenCancelChildren {
         val state = TextFieldState(initialSelection = TextRange(0))
         val texts = mutableListOf<TextFieldCharSequence>()
-        val initialText = state.text
+        val initialSelection = state.selection
 
         launch(Dispatchers.Unconfined) {
-            state.forEachTextValue { texts += it }
+            state.valueAsFlow().collectLatest { texts += it }
         }
 
         state.edit {
@@ -491,9 +486,9 @@
         }
 
         assertThat(texts).hasSize(2)
-        assertThat(texts.last()).isSameInstanceAs(state.text)
+        assertThat(texts.last()).isSameInstanceAs(state.value)
         assertThat(texts.last().toString()).isEqualTo("hello")
-        assertThat(texts.last().selection).isEqualTo(initialText.selection)
+        assertThat(texts.last().selection).isEqualTo(initialSelection)
     }
 
     @Test
@@ -502,7 +497,7 @@
         val texts = mutableListOf<TextFieldCharSequence>()
 
         launch(Dispatchers.Unconfined) {
-            state.forEachTextValue { texts += it }
+            state.valueAsFlow().collectLatest { texts += it }
         }
 
         state.edit {
@@ -510,7 +505,7 @@
         }
 
         assertThat(texts).hasSize(2)
-        assertThat(texts.last()).isSameInstanceAs(state.text)
+        assertThat(texts.last()).isSameInstanceAs(state.value)
         assertThat(texts.last().toString()).isEqualTo("hello")
         assertThat(texts.last().selection).isEqualTo(TextRange(5))
     }
@@ -521,7 +516,7 @@
         val texts = mutableListOf<TextFieldCharSequence>()
 
         launch(Dispatchers.Unconfined) {
-            state.forEachTextValue { texts += it }
+            state.valueAsFlow().collectLatest { texts += it }
         }
 
         state.edit {
@@ -536,7 +531,7 @@
 
         assertThat(texts).hasSize(3)
         assertThat(texts[1].toString()).isEqualTo("hello")
-        assertThat(texts[2]).isSameInstanceAs(state.text)
+        assertThat(texts[2]).isSameInstanceAs(state.value)
         assertThat(texts[2].toString()).isEqualTo("hello world")
     }
 
@@ -547,7 +542,7 @@
             val texts = mutableListOf<TextFieldCharSequence>()
 
             launch(Dispatchers.Unconfined) {
-                state.forEachTextValue { texts += it }
+                state.valueAsFlow().collectLatest { texts += it }
             }
 
             state.edit {
@@ -556,7 +551,7 @@
                 placeCursorAtEnd()
             }
 
-            assertThat(texts.last()).isSameInstanceAs(state.text)
+            assertThat(texts.last()).isSameInstanceAs(state.value)
             assertThat(texts.last().toString()).isEqualTo("hello world")
         }
 
@@ -567,7 +562,7 @@
             val texts = mutableListOf<TextFieldCharSequence>()
 
             launch(Dispatchers.Unconfined) {
-                state.forEachTextValue { texts += it }
+                state.valueAsFlow().collectLatest { texts += it }
             }
 
             val snapshot = Snapshot.takeMutableSnapshot()
@@ -583,7 +578,7 @@
             snapshot.apply()
             snapshot.dispose()
 
-            assertThat(texts.last()).isSameInstanceAs(state.text)
+            assertThat(texts.last()).isSameInstanceAs(state.value)
         }
 
     @Test
@@ -593,7 +588,7 @@
             val texts = mutableListOf<TextFieldCharSequence>()
 
             launch(Dispatchers.Unconfined) {
-                state.forEachTextValue { texts += it }
+                state.valueAsFlow().collectLatest { texts += it }
             }
 
             val snapshot = Snapshot.takeMutableSnapshot()
@@ -617,7 +612,7 @@
             val texts = mutableListOf<TextFieldCharSequence>()
 
             launch(Dispatchers.Unconfined) {
-                state.forEachTextValue {
+                state.valueAsFlow().collectLatest {
                     texts += it
                     awaitCancellation()
                 }
@@ -631,6 +626,32 @@
                 .inOrder()
         }
 
+    @Test
+    fun snapshotFlowOfText_onlyFiresIfContentChanges() {
+        runTestWithSnapshotsThenCancelChildren {
+            val state = TextFieldState()
+            val texts = mutableListOf<CharSequence>()
+
+            launch(Dispatchers.Unconfined) {
+                snapshotFlow {
+                    state.text
+                }.collect {
+                    texts += it
+                }
+            }
+
+            state.edit { append("a") }
+            state.edit { append("b") }
+            state.edit { placeCursorBeforeCharAt(0) }
+            state.edit { placeCursorAtEnd() }
+            state.edit { append("c") }
+
+            assertThat(texts.map { it.toString() })
+                .containsExactly("", "a", "ab", "abc")
+                .inOrder()
+        }
+    }
+
     private fun runTestWithSnapshotsThenCancelChildren(testBody: suspend TestScope.() -> Unit) {
         val globalWriteObserverHandle = Snapshot.registerGlobalWriteObserver {
             // This is normally done by the compose runtime.
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt
index 168217a..bd3f689 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TextFieldStateInternalBufferTest.kt
@@ -36,7 +36,7 @@
         val firstValue = TextFieldCharSequence("ABCDE", TextRange.Zero)
         val state = TextFieldState(firstValue)
 
-        assertThat(state.text).isEqualTo(firstValue)
+        assertThat(state.value).isEqualTo(firstValue)
     }
 
     @Test
@@ -51,7 +51,7 @@
         }
 
         state.editAsUser { commitText("X", 1) }
-        val newState = state.text
+        val newState = state.value
 
         assertThat(newState.toString()).isEqualTo("XABCDE")
         assertThat(newState.selection.min).isEqualTo(1)
@@ -73,7 +73,7 @@
         }
 
         state.editAsUser { setSelection(0, 2) }
-        val newState = state.text
+        val newState = state.value
 
         assertThat(newState.toString()).isEqualTo("ABCDE")
         assertThat(newState.selection.min).isEqualTo(0)
@@ -241,13 +241,13 @@
         val newValue =
             TextFieldCharSequence(
                 "cd",
-                state.text.selection,
-                state.text.composition
+                state.selection,
+                state.composition
             )
         state.resetStateAndNotifyIme(newValue)
 
         assertThat(state.text.toString()).isEqualTo(newValue.toString())
-        assertThat(state.text.composition).isNull()
+        assertThat(state.composition).isNull()
         assertThat(resetCalled).isEqualTo(1)
         assertThat(selectionCalled).isEqualTo(1)
     }
@@ -267,13 +267,13 @@
         val newValue =
             TextFieldCharSequence(
                 state.text,
-                state.text.selection,
-                state.text.composition
+                state.selection,
+                state.composition
             )
         state.resetStateAndNotifyIme(newValue)
 
         assertThat(state.text.toString()).isEqualTo(newValue.toString())
-        assertThat(state.text.composition).isEqualTo(composition)
+        assertThat(state.composition).isEqualTo(composition)
     }
 
     @Test
@@ -290,13 +290,13 @@
         val newValue =
             TextFieldCharSequence(
                 state.text,
-                state.text.selection,
+                state.selection,
                 composition = TextRange(0, 2)
             )
         state.resetStateAndNotifyIme(newValue)
 
         assertThat(state.text.toString()).isEqualTo(newValue.toString())
-        assertThat(state.text.composition).isNull()
+        assertThat(state.composition).isNull()
     }
 
     @Test
@@ -312,13 +312,13 @@
         // change the composition
         val newValue = TextFieldCharSequence(
             state.text,
-            state.text.selection,
+            state.selection,
             composition = TextRange(0, 1)
         )
         state.resetStateAndNotifyIme(newValue)
 
         assertThat(state.text.toString()).isEqualTo(newValue.toString())
-        assertThat(state.text.composition).isNull()
+        assertThat(state.composition).isNull()
     }
 
     @Test
@@ -340,13 +340,13 @@
         val newValue = TextFieldCharSequence(
             state.text,
             selection = newSelection,
-            composition = state.text.composition
+            composition = state.composition
         )
         state.resetStateAndNotifyIme(newValue)
 
         assertThat(state.text.toString()).isEqualTo(newValue.toString())
-        assertThat(state.text.composition).isEqualTo(composition)
-        assertThat(state.text.selection).isEqualTo(newSelection)
+        assertThat(state.composition).isEqualTo(composition)
+        assertThat(state.selection).isEqualTo(newSelection)
     }
 
     @Test
@@ -541,7 +541,7 @@
 
         state.editAsUser { setComposingRegion(2, 3) }
 
-        assertThat(state.text.composition).isEqualTo(TextRange(2, 3))
+        assertThat(state.composition).isEqualTo(TextRange(2, 3))
     }
 
     @Test
@@ -552,7 +552,7 @@
 
         state.editAsUser { setComposingRegion(2, 3) }
 
-        assertThat(state.text.composition).isEqualTo(TextRange(2, 3))
+        assertThat(state.composition).isEqualTo(TextRange(2, 3))
     }
 
     private fun TextFieldState(
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt
index 029b76d..f9ce97e 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextSelectionMovementTest.kt
@@ -43,7 +43,7 @@
 
         calculateNextCursorPosition(transformedState)
 
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(3))
     }
 
@@ -59,7 +59,7 @@
 
         calculatePreviousCursorPosition(transformedState)
 
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(1))
     }
 
@@ -73,19 +73,19 @@
             TransformedTextFieldState(state, outputTransformation = outputTransformation)
 
         calculateNextCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start))
 
         calculateNextCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(3))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.End))
 
         calculateNextCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(4))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.End))
@@ -102,19 +102,19 @@
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(4))
 
         calculatePreviousCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(3))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.End))
 
         calculatePreviousCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(1))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start))
 
         calculatePreviousCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(0))
+        assertThat(state.selection).isEqualTo(TextRange(0))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(0))
         assertThat(transformedState.selectionWedgeAffinity)
             .isEqualTo(SelectionWedgeAffinity(WedgeAffinity.Start))
@@ -130,11 +130,11 @@
             TransformedTextFieldState(state, outputTransformation = outputTransformation)
 
         calculateNextCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1, 3))
+        assertThat(state.selection).isEqualTo(TextRange(1, 3))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(1))
 
         calculateNextCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(4))
+        assertThat(state.selection).isEqualTo(TextRange(4))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(2))
     }
 
@@ -149,11 +149,11 @@
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(2))
 
         calculatePreviousCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(1, 3))
+        assertThat(state.selection).isEqualTo(TextRange(1, 3))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(1))
 
         calculatePreviousCursorPosition(transformedState)
-        assertThat(state.text.selection).isEqualTo(TextRange(0))
+        assertThat(state.selection).isEqualTo(TextRange(0))
         assertThat(transformedState.visualText.selection).isEqualTo(TextRange(0))
     }
 
diff --git a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt
index 6c07153..ea0d8d1 100644
--- a/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt
+++ b/compose/foundation/foundation/src/androidUnitTest/kotlin/androidx/compose/foundation/text/input/internal/undo/TextUndoTest.kt
@@ -76,7 +76,7 @@
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("ac")
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("")
@@ -160,15 +160,15 @@
         state.typeAtStart("c") // "|a" -> "c|a"
 
         assertThat(state.text.toString()).isEqualTo("ca")
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("a")
-        assertThat(state.text.selection).isEqualTo(TextRange(0))
+        assertThat(state.selection).isEqualTo(TextRange(0))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("ab")
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("")
@@ -182,15 +182,15 @@
         state.typeAtEnd("g") // "defg|"
 
         assertThat(state.text.toString()).isEqualTo("defg")
-        assertThat(state.text.selection).isEqualTo(TextRange(4))
+        assertThat(state.selection).isEqualTo(TextRange(4))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("def")
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("abcd")
-        assertThat(state.text.selection).isEqualTo(TextRange(4))
+        assertThat(state.selection).isEqualTo(TextRange(4))
     }
 
     @Test
@@ -202,15 +202,15 @@
         state.deleteAt(2) // "de|"
 
         assertThat(state.text.toString()).isEqualTo("de")
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("def")
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("ab")
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
     }
 
     @Test
@@ -222,15 +222,15 @@
         state.typeAtEnd("c") // "ab\nc|"
 
         assertThat(state.text.toString()).isEqualTo("ab\nc")
-        assertThat(state.text.selection).isEqualTo(TextRange(4))
+        assertThat(state.selection).isEqualTo(TextRange(4))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("ab\n")
-        assertThat(state.text.selection).isEqualTo(TextRange(3))
+        assertThat(state.selection).isEqualTo(TextRange(3))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("ab")
-        assertThat(state.text.selection).isEqualTo(TextRange(2))
+        assertThat(state.selection).isEqualTo(TextRange(2))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("")
@@ -243,11 +243,11 @@
         state.type("d") // "d|c"
 
         assertThat(state.text.toString()).isEqualTo("dc")
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
 
         state.undoState.undo()
         assertThat(state.text.toString()).isEqualTo("abc")
-        assertThat(state.text.selection).isEqualTo(TextRange(2, 0))
+        assertThat(state.selection).isEqualTo(TextRange(2, 0))
     }
 
     @Test
@@ -258,14 +258,14 @@
         state.type("e") // "e|cd"
 
         assertThat(state.text.toString()).isEqualTo("ecd")
-        assertThat(state.text.selection).isEqualTo(TextRange(1))
+        assertThat(state.selection).isEqualTo(TextRange(1))
 
         state.undoState.undo() // "|ab|cd"
         state.undoState.undo() // "|abc"
         state.undoState.redo() // "abcd|"
 
         assertThat(state.text.toString()).isEqualTo("abcd")
-        assertThat(state.text.selection).isEqualTo(TextRange(4, 4))
+        assertThat(state.selection).isEqualTo(TextRange(4, 4))
     }
 
     @Test
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldCharSequence.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldCharSequence.kt
index 5b32a59..14daca4 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldCharSequence.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldCharSequence.kt
@@ -77,6 +77,16 @@
 ): TextFieldCharSequence = TextFieldCharSequenceWrapper(text, selection, composition)
 
 /**
+ * Returns the backing CharSequence object that this TextFieldCharSequence is wrapping. This is
+ * useful for external equality comparisons that cannot use [TextFieldCharSequence.contentEquals].
+ */
+internal fun TextFieldCharSequence.getBackingCharSequence(): CharSequence {
+    return when (this) {
+        is TextFieldCharSequenceWrapper -> this.text
+    }
+}
+
+/**
  * Copies the contents of this sequence from [[sourceStartIndex], [sourceEndIndex]) into
  * [destination] starting at [destinationOffset].
  */
@@ -94,11 +104,22 @@
 
 @OptIn(ExperimentalFoundationApi::class)
 private class TextFieldCharSequenceWrapper(
-    private val text: CharSequence,
+    text: CharSequence,
     selection: TextRange,
     composition: TextRange?
 ) : TextFieldCharSequence {
 
+    /**
+     * If this TextFieldCharSequence is actually a copy of another, make sure to use the backing
+     * CharSequence object to stop unnecessary nesting and logic that depends on exact equality of
+     * CharSequence comparison that's using [CharSequence.equals].
+     */
+    val text: CharSequence = if (text is TextFieldCharSequenceWrapper) {
+        text.text
+    } else {
+        text
+    }
+
     override val length: Int
         get() = text.length
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt
index 2a587b9..2e879b1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt
@@ -35,7 +35,6 @@
 import androidx.compose.ui.text.coerceIn
 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(
@@ -49,8 +48,11 @@
  * cursor or selection.
  *
  * To change the text field contents programmatically, call [edit], [setTextAndSelectAll],
- * [setTextAndPlaceCursorAtEnd], or [clearText]. To observe the value of the field over time, call
- * [forEachTextValue] or [textAsFlow].
+ * [setTextAndPlaceCursorAtEnd], or [clearText]. Individual parts of the state like [text],
+ * [selection], or [composition] can be read from any snapshot restart scope like Composable
+ * functions. To observe these members from outside a restart scope, use
+ * `snapshotFlow { textFieldState.text }` or `snapshotFlow { textFieldState.selection }`. To
+ * observe the entirety of state including text, selection, and composition, call [valueAsFlow].
  *
  * When instantiating this class from a composable, use [rememberTextFieldState] to automatically
  * save and restore the field state. For more advanced use cases, pass [TextFieldState.Saver] to
@@ -96,29 +98,67 @@
     private var isEditing: Boolean by mutableStateOf(false)
 
     /**
-     * The current text and selection. This value will automatically update when the user enters
-     * text or otherwise changes the text field contents. To change it programmatically, call
-     * [edit].
+     * The current text, selection, and composing region. This value will automatically update when
+     * the user enters text or otherwise changes the text field contents. To change it
+     * programmatically, call [edit].
      *
      * This is backed by snapshot state, so reading this property in a restartable function (e.g.
      * a composable function) will cause the function to restart when the text field's value
      * changes.
      *
-     * To observe changes to this property outside a restartable function, see [forEachTextValue]
-     * and [textAsFlow].
+     * To observe changes to this property outside a restartable function, see [valueAsFlow].
      *
      * @sample androidx.compose.foundation.samples.BasicTextFieldTextDerivedStateSample
      *
      * @see edit
-     * @see forEachTextValue
-     * @see textAsFlow
+     * @see valueAsFlow
      */
-    var text: TextFieldCharSequence by mutableStateOf(
+    internal var value: TextFieldCharSequence by mutableStateOf(
         TextFieldCharSequence(initialText, initialSelection)
     )
         private set
 
     /**
+     * The current text content. This value will automatically update when the user enters text or
+     * otherwise changes the text field contents. To change it programmatically, call [edit].
+     *
+     * To observe changes to this property outside a restartable function, use
+     * `snapshotFlow { text }`.
+     *
+     * @sample androidx.compose.foundation.samples.BasicTextFieldTextValuesSample
+     *
+     * @see edit
+     * @see snapshotFlow
+     */
+    val text: CharSequence get() = value.getBackingCharSequence()
+
+    /**
+     * The current selection range. If the selection is collapsed, it represents cursor location.
+     * This value will automatically update when the user enters text or otherwise changes the text
+     * field selection range. To change it programmatically, call [edit].
+     *
+     * To observe changes to this property outside a restartable function, use
+     * `snapshotFlow { selection }`.
+     *
+     * @see edit
+     * @see snapshotFlow
+     * @see TextFieldCharSequence.selection
+     */
+    val selection: TextRange get() = value.selection
+
+    /**
+     * The current composing range dictated by the IME. If null, there is no composing region.
+     *
+     * To observe changes to this property outside a restartable function, use
+     * `snapshotFlow { composition }`.
+     *
+     * @see edit
+     * @see snapshotFlow
+     * @see TextFieldCharSequence.composition
+     */
+    val composition: TextRange? get() = value.composition
+
+    /**
      * Runs [block] with a mutable version of the current state. The block can make changes to the
      * text and cursor/selection. See the documentation on [TextFieldBuffer] for a more detailed
      * description of the available operations.
@@ -133,7 +173,7 @@
      * @see setTextAndSelectAll
      */
     inline fun edit(block: TextFieldBuffer.() -> Unit) {
-        val mutableValue = startEdit(text)
+        val mutableValue = startEdit()
         try {
             mutableValue.block()
             commitEdit(mutableValue)
@@ -143,7 +183,7 @@
     }
 
     override fun toString(): String =
-        "TextFieldState(selection=${text.selection}, text=\"$text\")"
+        "TextFieldState(selection=$selection, text=\"$text\")"
 
     /**
      * Undo history controller for this TextFieldState.
@@ -159,7 +199,7 @@
 
     @Suppress("ShowingMemberInHiddenClass")
     @PublishedApi
-    internal fun startEdit(value: TextFieldCharSequence): TextFieldBuffer {
+    internal fun startEdit(): TextFieldBuffer {
         check(!isEditing) {
             "TextFieldState does not support concurrent or nested editing."
         }
@@ -218,7 +258,7 @@
         undoBehavior: TextFieldEditUndoBehavior = TextFieldEditUndoBehavior.MergeIfPossible,
         block: EditingBuffer.() -> Unit
     ) {
-        val previousValue = text
+        val previousValue = value
 
         mainBuffer.changeTracker.clearChanges()
         mainBuffer.block()
@@ -249,7 +289,7 @@
      * a public API.
      */
     internal inline fun editWithNoSideEffects(block: EditingBuffer.() -> Unit) {
-        val previousValue = text
+        val previousValue = value
 
         mainBuffer.changeTracker.clearChanges()
         mainBuffer.block()
@@ -260,7 +300,7 @@
             composition = mainBuffer.composition
         )
 
-        text = afterEditValue
+        value = afterEditValue
         sendChangesToIme(
             oldValue = previousValue,
             newValue = afterEditValue,
@@ -281,24 +321,24 @@
         )
 
         if (inputTransformation == null) {
-            val oldValue = text
-            text = afterEditValue
+            val oldValue = value
+            value = afterEditValue
             sendChangesToIme(
                 oldValue = oldValue,
                 newValue = afterEditValue,
                 restartImeIfContentChanges = restartImeIfContentChanges
             )
-            recordEditForUndo(previousValue, text, mainBuffer.changeTracker, undoBehavior)
+            recordEditForUndo(previousValue, value, mainBuffer.changeTracker, undoBehavior)
             return
         }
 
-        val oldValue = text
+        val oldValue = value
 
         // if only difference is composition, don't run filter, don't send it to undo manager
         if (afterEditValue.contentEquals(oldValue) &&
             afterEditValue.selection == oldValue.selection
         ) {
-            text = afterEditValue
+            value = afterEditValue
             sendChangesToIme(
                 oldValue = oldValue,
                 newValue = afterEditValue,
@@ -307,22 +347,22 @@
             return
         }
 
-        val mutableValue = TextFieldBuffer(
+        val textFieldBuffer = TextFieldBuffer(
             initialValue = afterEditValue,
             sourceValue = oldValue,
             initialChanges = mainBuffer.changeTracker
         )
         inputTransformation.transformInput(
             originalValue = oldValue,
-            valueWithChanges = mutableValue
+            valueWithChanges = textFieldBuffer
         )
         // If neither the text nor the selection changed, we want to preserve the composition.
         // Otherwise, the IME will reset it anyway.
-        val afterFilterValue = mutableValue.toTextFieldCharSequence(
+        val afterFilterValue = textFieldBuffer.toTextFieldCharSequence(
             composition = afterEditValue.composition
         )
         if (afterFilterValue == afterEditValue) {
-            text = afterFilterValue
+            value = afterFilterValue
             sendChangesToIme(
                 oldValue = oldValue,
                 newValue = afterEditValue,
@@ -332,7 +372,7 @@
             resetStateAndNotifyIme(afterFilterValue)
         }
         // mutableValue contains all the changes from user and the filter.
-        recordEditForUndo(previousValue, text, mutableValue.changes, undoBehavior)
+        recordEditForUndo(previousValue, value, textFieldBuffer.changes, undoBehavior)
     }
 
     /**
@@ -445,15 +485,15 @@
         }
 
         val finalValue = TextFieldCharSequence(
-            if (textChanged) newValue else bufferState,
-            mainBuffer.selection,
-            mainBuffer.composition
+            text = if (textChanged) newValue else bufferState,
+            selection = mainBuffer.selection,
+            composition = mainBuffer.composition
         )
 
         // value must be set before notifyImeListeners are called. Even though we are sending the
         // previous and current values, a system callback may request the latest state e.g. IME
         // restartInput call is handled before notifyImeListeners return.
-        text = finalValue
+        value = finalValue
 
         sendChangesToIme(
             oldValue = bufferState,
@@ -489,8 +529,8 @@
         override fun SaverScope.save(value: TextFieldState): Any? {
             return listOf(
                 value.text.toString(),
-                value.text.selection.start,
-                value.text.selection.end,
+                value.selection.start,
+                value.selection.end,
                 with(TextUndoManager.Companion.Saver) {
                     save(value.textUndoManager)
                 }
@@ -514,13 +554,14 @@
 }
 
 /**
- * Returns a [Flow] of the values of [TextFieldState.text] as seen from the global snapshot.
+ * Returns a [Flow] of the values of [TextFieldState.text], [TextFieldState.selection], and
+ * [TextFieldState.composition] as seen from the global snapshot.
  * The initial value is emitted immediately when the flow is collected.
  *
  * @sample androidx.compose.foundation.samples.BasicTextFieldTextValuesSample
  */
 @ExperimentalFoundationApi
-fun TextFieldState.textAsFlow(): Flow<TextFieldCharSequence> = snapshotFlow { text }
+fun TextFieldState.valueAsFlow(): Flow<TextFieldCharSequence> = snapshotFlow { value }
 
 /**
  * Create and remember a [TextFieldState]. The state is remembered using [rememberSaveable] and so
@@ -609,28 +650,3 @@
         placeCursorAtEnd()
     }
 }
-
-/**
- * Invokes [block] with the value of [TextFieldState.text], and every time the value is changed.
- *
- * The caller will be suspended until its coroutine is cancelled. If the text is changed while
- * [block] is suspended, [block] will be cancelled and re-executed with the new value immediately.
- * [block] will never be executed concurrently with itself.
- *
- * To get access to a [Flow] of [TextFieldState.text] over time, use [textAsFlow].
- *
- * Warning: Do not update the value of the [TextFieldState] from [block]. If you want to perform
- * either a side effect when text is changed, or filter it in some way, use an
- * [InputTransformation].
- *
- * @sample androidx.compose.foundation.samples.BasicTextFieldForEachTextValueSample
- *
- * @see textAsFlow
- */
-@ExperimentalFoundationApi
-suspend fun TextFieldState.forEachTextValue(
-    block: suspend (TextFieldCharSequence) -> Unit
-): Nothing {
-    textAsFlow().collectLatest(block)
-    error("textAsFlow expected not to complete without exception")
-}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt
index c5b1415..990e981 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/TransformedTextFieldState.kt
@@ -116,7 +116,7 @@
             derivedStateOf {
                 // text is a state read. transformation may also perform state reads when ran.
                 calculateTransformedText(
-                    untransformedText = textFieldState.text,
+                    untransformedValue = textFieldState.value,
                     outputTransformation = transformation,
                     wedgeAffinity = selectionWedgeAffinity
                 )
@@ -129,7 +129,7 @@
                 calculateTransformedText(
                     // These are state reads. codepointTransformation may also perform state reads
                     // when ran.
-                    untransformedText = outputTransformedText?.value?.text ?: textFieldState.text,
+                    untransformedValue = outputTransformedText?.value?.text ?: textFieldState.value,
                     codepointTransformation = transformation,
                     wedgeAffinity = selectionWedgeAffinity
                 )
@@ -141,7 +141,7 @@
      * [CodepointTransformation] applied.
      */
     val untransformedText: TextFieldCharSequence
-        get() = textFieldState.text
+        get() = textFieldState.value
 
     /**
      * The text that should be presented to the user in most cases. If an [OutputTransformation] is
@@ -433,22 +433,22 @@
 
         /**
          * Applies an [OutputTransformation] to a [TextFieldCharSequence], returning the
-         * transformed text content, the selection/cursor from the [untransformedText] mapped to the
+         * transformed text content, the selection/cursor from the [untransformedValue] mapped to the
          * offsets in the transformed text, and an [OffsetMappingCalculator] that can be used to map
          * offsets in both directions between the transformed and untransformed text.
          *
-         * This function is relatively expensive, since it creates a copy of [untransformedText], so
+         * This function is relatively expensive, since it creates a copy of [untransformedValue], so
          * its result should be cached.
          */
         @kotlin.jvm.JvmStatic
         private fun calculateTransformedText(
-            untransformedText: TextFieldCharSequence,
+            untransformedValue: TextFieldCharSequence,
             outputTransformation: OutputTransformation,
             wedgeAffinity: SelectionWedgeAffinity
         ): TransformedText? {
             val offsetMappingCalculator = OffsetMappingCalculator()
             val buffer = TextFieldBuffer(
-                initialValue = untransformedText,
+                initialValue = untransformedValue,
                 offsetMappingCalculator = offsetMappingCalculator
             )
 
@@ -464,11 +464,11 @@
                 // Pass the calculator explicitly since the one on transformedText won't be updated
                 // yet.
                 selection = mapToTransformed(
-                    range = untransformedText.selection,
+                    range = untransformedValue.selection,
                     mapping = offsetMappingCalculator,
                     wedgeAffinity = wedgeAffinity
                 ),
-                composition = untransformedText.composition?.let {
+                composition = untransformedValue.composition?.let {
                     mapToTransformed(
                         range = it,
                         mapping = offsetMappingCalculator,
@@ -481,16 +481,16 @@
 
         /**
          * Applies a [CodepointTransformation] to a [TextFieldCharSequence], returning the
-         * transformed text content, the selection/cursor from the [untransformedText] mapped to the
+         * transformed text content, the selection/cursor from the [untransformedValue] mapped to the
          * offsets in the transformed text, and an [OffsetMappingCalculator] that can be used to map
          * offsets in both directions between the transformed and untransformed text.
          *
-         * This function is relatively expensive, since it creates a copy of [untransformedText], so
+         * This function is relatively expensive, since it creates a copy of [untransformedValue], so
          * its result should be cached.
          */
         @kotlin.jvm.JvmStatic
         private fun calculateTransformedText(
-            untransformedText: TextFieldCharSequence,
+            untransformedValue: TextFieldCharSequence,
             codepointTransformation: CodepointTransformation,
             wedgeAffinity: SelectionWedgeAffinity
         ): TransformedText? {
@@ -498,10 +498,10 @@
 
             // This is the call to external code. Returns same instance if no codepoints change.
             val transformedText =
-                untransformedText.toVisualText(codepointTransformation, offsetMappingCalculator)
+                untransformedValue.toVisualText(codepointTransformation, offsetMappingCalculator)
 
             // Avoid allocations + mapping if there weren't actually any transformations.
-            if (transformedText === untransformedText) {
+            if (transformedText === untransformedValue) {
                 return null
             }
 
@@ -510,11 +510,11 @@
                 // Pass the calculator explicitly since the one on transformedText won't be updated
                 // yet.
                 selection = mapToTransformed(
-                    untransformedText.selection,
+                    untransformedValue.selection,
                     offsetMappingCalculator,
                     wedgeAffinity
                 ),
-                composition = untransformedText.composition?.let {
+                composition = untransformedValue.composition?.let {
                     mapToTransformed(it, offsetMappingCalculator, wedgeAffinity)
                 }
             )
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index e58e6b2f..6dc536c 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -1068,8 +1068,10 @@
   }
 
   public final class NavigationDrawerKt {
+    method @androidx.compose.runtime.Composable public static void DismissibleDrawerSheet(androidx.compose.material3.DrawerState drawerState, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void DismissibleDrawerSheet(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void DismissibleNavigationDrawer(kotlin.jvm.functions.Function0<kotlin.Unit> drawerContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.DrawerState drawerState, optional boolean gesturesEnabled, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void ModalDrawerSheet(androidx.compose.material3.DrawerState drawerState, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void ModalDrawerSheet(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void ModalNavigationDrawer(kotlin.jvm.functions.Function0<kotlin.Unit> drawerContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.DrawerState drawerState, optional boolean gesturesEnabled, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void NavigationDrawerItem(kotlin.jvm.functions.Function0<kotlin.Unit> label, boolean selected, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? badge, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.NavigationDrawerItemColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
@@ -2105,6 +2107,46 @@
 
 }
 
+package androidx.compose.material3.carousel {
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class CarouselDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior multiBrowseFlingBehavior(androidx.compose.material3.carousel.CarouselState state, optional androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec);
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior noSnapFlingBehavior();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior singleAdvanceFlingBehavior(androidx.compose.material3.carousel.CarouselState state, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec);
+    field public static final androidx.compose.material3.carousel.CarouselDefaults INSTANCE;
+  }
+
+  public final class CarouselKt {
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void HorizontalMultiBrowseCarousel(androidx.compose.material3.carousel.CarouselState state, float preferredItemWidth, optional androidx.compose.ui.Modifier modifier, optional float itemSpacing, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional float minSmallItemWidth, optional float maxSmallItemWidth, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function2<? super androidx.compose.material3.carousel.CarouselScope,? super java.lang.Integer,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void HorizontalUncontainedCarousel(androidx.compose.material3.carousel.CarouselState state, float itemWidth, optional androidx.compose.ui.Modifier modifier, optional float itemSpacing, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function2<? super androidx.compose.material3.carousel.CarouselScope,? super java.lang.Integer,kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public sealed interface CarouselScope {
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class CarouselState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public CarouselState(optional int currentItem, optional @FloatRange(from=-0.5, to=0.5) float currentItemOffsetFraction, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
+    method public float dispatchRawDelta(float delta);
+    method public androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>> getItemCountState();
+    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 void setItemCountState(androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>>);
+    property public boolean isScrollInProgress;
+    property public final androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>> itemCountState;
+    field public static final androidx.compose.material3.carousel.CarouselState.Companion Companion;
+  }
+
+  public static final class CarouselState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.compose.material3.carousel.CarouselState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.compose.material3.carousel.CarouselState,?> Saver;
+  }
+
+  public final class CarouselStateKt {
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static androidx.compose.material3.carousel.CarouselState rememberCarouselState(optional int initialItem, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
+  }
+
+}
+
 package androidx.compose.material3.pulltorefresh {
 
   @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class PullToRefreshDefaults {
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index e58e6b2f..6dc536c 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -1068,8 +1068,10 @@
   }
 
   public final class NavigationDrawerKt {
+    method @androidx.compose.runtime.Composable public static void DismissibleDrawerSheet(androidx.compose.material3.DrawerState drawerState, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void DismissibleDrawerSheet(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void DismissibleNavigationDrawer(kotlin.jvm.functions.Function0<kotlin.Unit> drawerContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.DrawerState drawerState, optional boolean gesturesEnabled, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static void ModalDrawerSheet(androidx.compose.material3.DrawerState drawerState, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void ModalDrawerSheet(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.Shape drawerShape, optional long drawerContainerColor, optional long drawerContentColor, optional float drawerTonalElevation, optional androidx.compose.foundation.layout.WindowInsets windowInsets, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void ModalNavigationDrawer(kotlin.jvm.functions.Function0<kotlin.Unit> drawerContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.DrawerState drawerState, optional boolean gesturesEnabled, optional long scrimColor, kotlin.jvm.functions.Function0<kotlin.Unit> content);
     method @androidx.compose.runtime.Composable public static void NavigationDrawerItem(kotlin.jvm.functions.Function0<kotlin.Unit> label, boolean selected, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? badge, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.NavigationDrawerItemColors colors, optional androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource);
@@ -2105,6 +2107,46 @@
 
 }
 
+package androidx.compose.material3.carousel {
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class CarouselDefaults {
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior multiBrowseFlingBehavior(androidx.compose.material3.carousel.CarouselState state, optional androidx.compose.animation.core.DecayAnimationSpec<java.lang.Float> decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec);
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior noSnapFlingBehavior();
+    method @androidx.compose.runtime.Composable public androidx.compose.foundation.gestures.TargetedFlingBehavior singleAdvanceFlingBehavior(androidx.compose.material3.carousel.CarouselState state, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> snapAnimationSpec);
+    field public static final androidx.compose.material3.carousel.CarouselDefaults INSTANCE;
+  }
+
+  public final class CarouselKt {
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void HorizontalMultiBrowseCarousel(androidx.compose.material3.carousel.CarouselState state, float preferredItemWidth, optional androidx.compose.ui.Modifier modifier, optional float itemSpacing, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional float minSmallItemWidth, optional float maxSmallItemWidth, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function2<? super androidx.compose.material3.carousel.CarouselScope,? super java.lang.Integer,kotlin.Unit> content);
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static void HorizontalUncontainedCarousel(androidx.compose.material3.carousel.CarouselState state, float itemWidth, optional androidx.compose.ui.Modifier modifier, optional float itemSpacing, optional androidx.compose.foundation.gestures.TargetedFlingBehavior flingBehavior, optional androidx.compose.foundation.layout.PaddingValues contentPadding, kotlin.jvm.functions.Function2<? super androidx.compose.material3.carousel.CarouselScope,? super java.lang.Integer,kotlin.Unit> content);
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public sealed interface CarouselScope {
+  }
+
+  @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class CarouselState implements androidx.compose.foundation.gestures.ScrollableState {
+    ctor public CarouselState(optional int currentItem, optional @FloatRange(from=-0.5, to=0.5) float currentItemOffsetFraction, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
+    method public float dispatchRawDelta(float delta);
+    method public androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>> getItemCountState();
+    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 void setItemCountState(androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>>);
+    property public boolean isScrollInProgress;
+    property public final androidx.compose.runtime.MutableState<kotlin.jvm.functions.Function0<java.lang.Integer>> itemCountState;
+    field public static final androidx.compose.material3.carousel.CarouselState.Companion Companion;
+  }
+
+  public static final class CarouselState.Companion {
+    method public androidx.compose.runtime.saveable.Saver<androidx.compose.material3.carousel.CarouselState,?> getSaver();
+    property public final androidx.compose.runtime.saveable.Saver<androidx.compose.material3.carousel.CarouselState,?> Saver;
+  }
+
+  public final class CarouselStateKt {
+    method @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api @androidx.compose.runtime.Composable public static androidx.compose.material3.carousel.CarouselState rememberCarouselState(optional int initialItem, kotlin.jvm.functions.Function0<java.lang.Integer> itemCount);
+  }
+
+}
+
 package androidx.compose.material3.pulltorefresh {
 
   @SuppressCompatibility @androidx.compose.material3.ExperimentalMaterial3Api public final class PullToRefreshDefaults {
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
index 814b0841..af81870 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Components.kt
@@ -444,7 +444,7 @@
     BottomSheets,
     Buttons,
     Card,
-    // Carousel, // TODO: Re-enable when ready
+    Carousel,
     Checkboxes,
     Chips,
     DatePickers,
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
index e7b564a..037d0dbf 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
@@ -39,7 +39,6 @@
 import androidx.compose.material3.samples.ButtonSample
 import androidx.compose.material3.samples.ButtonWithIconSample
 import androidx.compose.material3.samples.CardSample
-import androidx.compose.material3.samples.CarouselSample
 import androidx.compose.material3.samples.CheckboxSample
 import androidx.compose.material3.samples.CheckboxWithTextSample
 import androidx.compose.material3.samples.ChipGroupReflowSample
@@ -79,6 +78,8 @@
 import androidx.compose.material3.samples.FilterChipSample
 import androidx.compose.material3.samples.FilterChipWithLeadingIconSample
 import androidx.compose.material3.samples.FloatingActionButtonSample
+import androidx.compose.material3.samples.HorizontalMultiBrowseCarouselSample
+import androidx.compose.material3.samples.HorizontalUncontainedCarouselSample
 import androidx.compose.material3.samples.IconButtonSample
 import androidx.compose.material3.samples.IconToggleButtonSample
 import androidx.compose.material3.samples.IndeterminateCircularProgressIndicatorSample
@@ -326,11 +327,18 @@
 private const val CarouselExampleSourceUrl = "$SampleSourceUrl/CarouselSamples.kt"
 val CarouselExamples = listOf(
     Example(
-        name = ::CarouselSample.name,
+        name = ::HorizontalMultiBrowseCarouselSample.name,
         description = CarouselExampleDescription,
         sourceUrl = CarouselExampleSourceUrl
     ) {
-        CarouselSample()
+        HorizontalMultiBrowseCarouselSample()
+    },
+    Example(
+        name = ::HorizontalUncontainedCarouselSample.name,
+        description = CarouselExampleDescription,
+        sourceUrl = CarouselExampleSourceUrl
+    ) {
+        HorizontalUncontainedCarouselSample()
     }
 )
 
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
index a55a9bd..5410945 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/CarouselSamples.kt
@@ -17,16 +17,18 @@
 package androidx.compose.material3.samples
 
 import androidx.annotation.DrawableRes
+import androidx.annotation.Sampled
 import androidx.annotation.StringRes
 import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel
+import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
+import androidx.compose.material3.carousel.rememberCarouselState
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.ContentScale
@@ -35,16 +37,19 @@
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 
+@OptIn(ExperimentalMaterial3Api::class)
 @Preview
+@Sampled
 @Composable
-fun CarouselSample() {
+fun HorizontalMultiBrowseCarouselSample() {
+
     data class CarouselItem(
         val id: Int,
         @DrawableRes val imageResId: Int,
         @StringRes val contentDescriptionResId: Int
     )
 
-    val Items = listOf(
+    val items = listOf(
         CarouselItem(0, R.drawable.carousel_image_1, R.string.carousel_image_1_description),
         CarouselItem(1, R.drawable.carousel_image_2, R.string.carousel_image_2_description),
         CarouselItem(2, R.drawable.carousel_image_3, R.string.carousel_image_3_description),
@@ -52,23 +57,69 @@
         CarouselItem(4, R.drawable.carousel_image_5, R.string.carousel_image_5_description),
     )
 
-    LazyRow(
-        modifier = Modifier.fillMaxWidth(),
-        state = rememberLazyListState()
-    ) {
-        itemsIndexed(Items) { _, item ->
-            Card(
-                modifier = Modifier
-                    .width(350.dp)
-                    .height(200.dp),
-            ) {
-                Image(
-                    painter = painterResource(id = item.imageResId),
-                    contentDescription = stringResource(item.contentDescriptionResId),
-                    modifier = Modifier.fillMaxSize(),
-                    contentScale = ContentScale.Crop
-                )
-            }
+    HorizontalMultiBrowseCarousel(
+        state = rememberCarouselState { items.count() },
+        modifier = Modifier
+            .width(412.dp)
+            .height(221.dp),
+        preferredItemWidth = 186.dp,
+        itemSpacing = 8.dp,
+        contentPadding = PaddingValues(horizontal = 16.dp)
+    ) { i ->
+        val item = items[i]
+        Card(
+            modifier = Modifier
+                .height(205.dp)
+        ) {
+            Image(
+                painter = painterResource(id = item.imageResId),
+                contentDescription = stringResource(item.contentDescriptionResId),
+                modifier = Modifier.fillMaxSize(),
+                contentScale = ContentScale.Crop
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Sampled
+@Composable
+fun HorizontalUncontainedCarouselSample() {
+
+    data class CarouselItem(
+        val id: Int,
+        @DrawableRes val imageResId: Int,
+        @StringRes val contentDescriptionResId: Int
+    )
+
+    val items = listOf(
+        CarouselItem(0, R.drawable.carousel_image_1, R.string.carousel_image_1_description),
+        CarouselItem(1, R.drawable.carousel_image_2, R.string.carousel_image_2_description),
+        CarouselItem(2, R.drawable.carousel_image_3, R.string.carousel_image_3_description),
+        CarouselItem(3, R.drawable.carousel_image_4, R.string.carousel_image_4_description),
+        CarouselItem(4, R.drawable.carousel_image_5, R.string.carousel_image_5_description),
+    )
+    HorizontalUncontainedCarousel(
+        state = rememberCarouselState { items.count() },
+        modifier = Modifier
+            .width(412.dp)
+            .height(221.dp),
+        itemWidth = 186.dp,
+        itemSpacing = 8.dp,
+        contentPadding = PaddingValues(horizontal = 16.dp)
+    ) { i ->
+        val item = items[i]
+        Card(
+            modifier = Modifier
+                .height(205.dp)
+        ) {
+            Image(
+                painter = painterResource(id = item.imageResId),
+                contentDescription = stringResource(item.contentDescriptionResId),
+                modifier = Modifier.fillMaxSize(),
+                contentScale = ContentScale.Crop
+            )
         }
     }
 }
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DrawerSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DrawerSamples.kt
index f21529e..6d07a51 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DrawerSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/DrawerSamples.kt
@@ -16,7 +16,6 @@
 
 package androidx.compose.material3.samples
 
-import androidx.activity.compose.BackHandler
 import androidx.annotation.Sampled
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
@@ -24,10 +23,27 @@
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Bookmarks
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.Dashboard
 import androidx.compose.material.icons.filled.Email
-import androidx.compose.material.icons.filled.Face
 import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.JoinFull
+import androidx.compose.material.icons.filled.Keyboard
+import androidx.compose.material.icons.filled.Laptop
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material.icons.filled.Navigation
+import androidx.compose.material.icons.filled.Outbox
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.filled.QrCode
+import androidx.compose.material.icons.filled.Radio
 import androidx.compose.material3.Button
 import androidx.compose.material3.DismissibleDrawerSheet
 import androidx.compose.material3.DismissibleNavigationDrawer
@@ -58,24 +74,45 @@
     val drawerState = rememberDrawerState(DrawerValue.Closed)
     val scope = rememberCoroutineScope()
     // icons to mimic drawer destinations
-    val items = listOf(Icons.Default.Favorite, Icons.Default.Face, Icons.Default.Email)
+    val items = listOf(
+        Icons.Default.AccountCircle,
+        Icons.Default.Bookmarks,
+        Icons.Default.CalendarMonth,
+        Icons.Default.Dashboard,
+        Icons.Default.Email,
+        Icons.Default.Favorite,
+        Icons.Default.Group,
+        Icons.Default.Headphones,
+        Icons.Default.Image,
+        Icons.Default.JoinFull,
+        Icons.Default.Keyboard,
+        Icons.Default.Laptop,
+        Icons.Default.Map,
+        Icons.Default.Navigation,
+        Icons.Default.Outbox,
+        Icons.Default.PushPin,
+        Icons.Default.QrCode,
+        Icons.Default.Radio,
+    )
     val selectedItem = remember { mutableStateOf(items[0]) }
     ModalNavigationDrawer(
         drawerState = drawerState,
         drawerContent = {
-            ModalDrawerSheet {
-                Spacer(Modifier.height(12.dp))
-                items.forEach { item ->
-                    NavigationDrawerItem(
-                        icon = { Icon(item, contentDescription = null) },
-                        label = { Text(item.name) },
-                        selected = item == selectedItem.value,
-                        onClick = {
-                            scope.launch { drawerState.close() }
-                            selectedItem.value = item
-                        },
-                        modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
-                    )
+            ModalDrawerSheet(drawerState) {
+                Column(Modifier.verticalScroll(rememberScrollState())) {
+                    Spacer(Modifier.height(12.dp))
+                    items.forEach { item ->
+                        NavigationDrawerItem(
+                            icon = { Icon(item, contentDescription = null) },
+                            label = { Text(item.name) },
+                            selected = item == selectedItem.value,
+                            onClick = {
+                                scope.launch { drawerState.close() }
+                                selectedItem.value = item
+                            },
+                            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
+                        )
+                    }
                 }
             }
         },
@@ -101,22 +138,43 @@
 @Composable
 fun PermanentNavigationDrawerSample() {
     // icons to mimic drawer destinations
-    val items = listOf(Icons.Default.Favorite, Icons.Default.Face, Icons.Default.Email)
+    val items = listOf(
+        Icons.Default.AccountCircle,
+        Icons.Default.Bookmarks,
+        Icons.Default.CalendarMonth,
+        Icons.Default.Dashboard,
+        Icons.Default.Email,
+        Icons.Default.Favorite,
+        Icons.Default.Group,
+        Icons.Default.Headphones,
+        Icons.Default.Image,
+        Icons.Default.JoinFull,
+        Icons.Default.Keyboard,
+        Icons.Default.Laptop,
+        Icons.Default.Map,
+        Icons.Default.Navigation,
+        Icons.Default.Outbox,
+        Icons.Default.PushPin,
+        Icons.Default.QrCode,
+        Icons.Default.Radio,
+    )
     val selectedItem = remember { mutableStateOf(items[0]) }
     PermanentNavigationDrawer(
         drawerContent = {
             PermanentDrawerSheet(Modifier.width(240.dp)) {
-                Spacer(Modifier.height(12.dp))
-                items.forEach { item ->
-                    NavigationDrawerItem(
-                        icon = { Icon(item, contentDescription = null) },
-                        label = { Text(item.name) },
-                        selected = item == selectedItem.value,
-                        onClick = {
-                            selectedItem.value = item
-                        },
-                        modifier = Modifier.padding(horizontal = 12.dp)
-                    )
+                Column(Modifier.verticalScroll(rememberScrollState())) {
+                    Spacer(Modifier.height(12.dp))
+                    items.forEach { item ->
+                        NavigationDrawerItem(
+                            icon = { Icon(item, contentDescription = null) },
+                            label = { Text(item.name) },
+                            selected = item == selectedItem.value,
+                            onClick = {
+                                selectedItem.value = item
+                            },
+                            modifier = Modifier.padding(horizontal = 12.dp)
+                        )
+                    }
                 }
             }
         },
@@ -140,30 +198,46 @@
     val drawerState = rememberDrawerState(DrawerValue.Closed)
     val scope = rememberCoroutineScope()
     // icons to mimic drawer destinations
-    val items = listOf(Icons.Default.Favorite, Icons.Default.Face, Icons.Default.Email)
+    val items = listOf(
+        Icons.Default.AccountCircle,
+        Icons.Default.Bookmarks,
+        Icons.Default.CalendarMonth,
+        Icons.Default.Dashboard,
+        Icons.Default.Email,
+        Icons.Default.Favorite,
+        Icons.Default.Group,
+        Icons.Default.Headphones,
+        Icons.Default.Image,
+        Icons.Default.JoinFull,
+        Icons.Default.Keyboard,
+        Icons.Default.Laptop,
+        Icons.Default.Map,
+        Icons.Default.Navigation,
+        Icons.Default.Outbox,
+        Icons.Default.PushPin,
+        Icons.Default.QrCode,
+        Icons.Default.Radio,
+    )
     val selectedItem = remember { mutableStateOf(items[0]) }
-    BackHandler(enabled = drawerState.isOpen) {
-        scope.launch {
-            drawerState.close()
-        }
-    }
 
     DismissibleNavigationDrawer(
         drawerState = drawerState,
         drawerContent = {
-            DismissibleDrawerSheet {
-                Spacer(Modifier.height(12.dp))
-                items.forEach { item ->
-                    NavigationDrawerItem(
-                        icon = { Icon(item, contentDescription = null) },
-                        label = { Text(item.name) },
-                        selected = item == selectedItem.value,
-                        onClick = {
-                            scope.launch { drawerState.close() }
-                            selectedItem.value = item
-                        },
-                        modifier = Modifier.padding(horizontal = 12.dp)
-                    )
+            DismissibleDrawerSheet(drawerState) {
+                Column(Modifier.verticalScroll(rememberScrollState())) {
+                    Spacer(Modifier.height(12.dp))
+                    items.forEach { item ->
+                        NavigationDrawerItem(
+                            icon = { Icon(item, contentDescription = null) },
+                            label = { Text(item.name) },
+                            selected = item == selectedItem.value,
+                            onClick = {
+                                scope.launch { drawerState.close() }
+                                selectedItem.value = item
+                            },
+                            modifier = Modifier.padding(horizontal = 12.dp)
+                        )
+                    }
                 }
             }
         },
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalNavigationDrawerScreenshotTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalNavigationDrawerScreenshotTest.kt
index 06d8653..3abf32b 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalNavigationDrawerScreenshotTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/ModalNavigationDrawerScreenshotTest.kt
@@ -19,11 +19,34 @@
 import android.os.Build
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.Place
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.ShoppingCart
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.runtime.Composable
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -117,6 +140,167 @@
             .captureToImage()
             .assertAgainstGolden(screenshotRule, goldenName)
     }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress0AndSwipeEdgeLeft() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0f, swipeEdgeLeft = true)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress0AndSwipeEdgeLeft")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress25AndSwipeEdgeLeft() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.25f, swipeEdgeLeft = true)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress25AndSwipeEdgeLeft")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress50AndSwipeEdgeLeft() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.5f, swipeEdgeLeft = true)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress50AndSwipeEdgeLeft")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress75AndSwipeEdgeLeft() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.75f, swipeEdgeLeft = true)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress75AndSwipeEdgeLeft")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress100AndSwipeEdgeLeft() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 1f, swipeEdgeLeft = true)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress100AndSwipeEdgeLeft")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress0AndSwipeEdgeRight() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0f, swipeEdgeLeft = false)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress0AndSwipeEdgeRight")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress25AndSwipeEdgeRight() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.25f, swipeEdgeLeft = false)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress25AndSwipeEdgeRight")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress50AndSwipeEdgeRight() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.5f, swipeEdgeLeft = false)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress50AndSwipeEdgeRight")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress75AndSwipeEdgeRight() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 0.75f, swipeEdgeLeft = false)
+        }
+        assertScreenshotAgainstGolden("navigationDrawer_predictiveBack_progress75AndSwipeEdgeRight")
+    }
+
+    @Test
+    fun predictiveBack_navigationDrawer_progress100AndSwipeEdgeRight() {
+        rule.setMaterialContent(lightColorScheme()) {
+            ModalNavigationDrawerPredictiveBack(progress = 1f, swipeEdgeLeft = false)
+        }
+        assertScreenshotAgainstGolden(
+            "navigationDrawer_predictiveBack_progress100AndSwipeEdgeRight"
+        )
+    }
 }
 
 private val ContainerTestTag = "container"
+
+private val items = listOf(
+    Icons.Default.AccountCircle,
+    Icons.Default.Build,
+    Icons.Default.Check,
+    Icons.Default.DateRange,
+    Icons.Default.Email,
+    Icons.Default.Favorite,
+    Icons.Default.Home,
+    Icons.Default.Info,
+    Icons.Default.Lock,
+    Icons.Default.Notifications,
+    Icons.Default.Place,
+    Icons.Default.Refresh,
+    Icons.Default.ShoppingCart,
+    Icons.Default.ThumbUp,
+    Icons.Default.Warning,
+)
+
+@Composable
+private fun ModalNavigationDrawerPredictiveBack(progress: Float, swipeEdgeLeft: Boolean) {
+    val maxScaleXDistanceGrow: Float
+    val maxScaleXDistanceShrink: Float
+    val maxScaleYDistance: Float
+    with(LocalDensity.current) {
+        maxScaleXDistanceGrow = PredictiveBackDrawerMaxScaleXDistanceGrow.toPx()
+        maxScaleXDistanceShrink = PredictiveBackDrawerMaxScaleXDistanceShrink.toPx()
+        maxScaleYDistance = PredictiveBackDrawerMaxScaleYDistance.toPx()
+    }
+
+    val drawerPredictiveBackState = DrawerPredictiveBackState().apply {
+        update(
+            progress = progress,
+            swipeEdgeLeft = swipeEdgeLeft,
+            isRtl = false,
+            maxScaleXDistanceGrow = maxScaleXDistanceGrow,
+            maxScaleXDistanceShrink = maxScaleXDistanceShrink,
+            maxScaleYDistance = maxScaleYDistance
+        )
+    }
+
+    ModalNavigationDrawer(
+        modifier = Modifier.testTag(ContainerTestTag),
+        drawerState = rememberDrawerState(DrawerValue.Open),
+        drawerContent = {
+            // Use the internal DrawerSheet instead of ModalDrawerSheet so we can simulate different
+            // back progress values for the test, and avoid the real PredictiveBackHandler.
+            DrawerSheet(
+                drawerPredictiveBackState,
+                DrawerDefaults.windowInsets,
+                Modifier,
+                DrawerDefaults.shape,
+                DrawerDefaults.modalContainerColor,
+                contentColorFor(DrawerDefaults.modalContainerColor),
+                DrawerDefaults.ModalDrawerElevation
+            ) {
+                Column(Modifier.verticalScroll(rememberScrollState())) {
+                    Spacer(Modifier.height(12.dp))
+                    items.forEach { item ->
+                        NavigationDrawerItem(
+                            icon = { Icon(item, contentDescription = null) },
+                            label = { Text(item.name) },
+                            selected = item == Icons.Default.AccountCircle,
+                            onClick = {},
+                            modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
+                        )
+                    }
+                }
+            }
+        },
+        content = {
+            Box(
+                Modifier
+                    .fillMaxSize()
+                    .background(MaterialTheme.colorScheme.background)
+            )
+        }
+    )
+}
diff --git a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
index 80e6eee..75d56ed 100644
--- a/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
+++ b/compose/material3/material3/src/androidInstrumentedTest/kotlin/androidx/compose/material3/carousel/CarouselTest.kt
@@ -21,6 +21,7 @@
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.TargetedFlingBehavior
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.width
@@ -193,9 +194,12 @@
             add(smallSize)
             add(xSmallSize, isAnchor = true)
         }
-        val strategy = Strategy { _, _ ->
-            keylineList
-        }.apply(availableSpace = 1000f, itemSpacing = 0f)
+        val strategy = Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 1000f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
         val outOfBoundsNum = calculateOutOfBounds(strategy)
         // With this strategy, we expect 3 loaded items
         val loadedItems = 3
@@ -219,7 +223,12 @@
                     add(56f)
                     add(10f, isAnchor = true)
                 }
-            }.apply(availableSpace = 380f, itemSpacing = 8f)
+            }.apply(
+                availableSpace = 380f,
+                itemSpacing = 8f,
+                beforeContentPadding = 0f,
+                afterContentPadding = 0f
+            )
 
             // Max offset should only add item spacing between each item
             val expectedMaxScrollOffset = (186f * 10) + (8f * 9) - 380f
@@ -278,6 +287,7 @@
                 flingBehavior = flingBehavior(state),
                 modifier = modifier.testTag(CarouselTestTag),
                 itemSpacing = 0.dp,
+                contentPadding = PaddingValues(0.dp),
                 content = content,
             )
         }
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/NavigationDrawer.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/NavigationDrawer.android.kt
new file mode 100644
index 0000000..535c610
--- /dev/null
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/NavigationDrawer.android.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import androidx.activity.BackEventCompat
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.compose.animation.core.animate
+import androidx.compose.material3.internal.PredictiveBack
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.launch
+
+/**
+ * Registers a [PredictiveBackHandler] and provides animation values in [DrawerPredictiveBackState]
+ * based on back progress.
+ *
+ * @param drawerState state of the drawer
+ * @param content content of the rest of the UI
+ */
+@Composable
+internal actual fun DrawerPredictiveBackHandler(
+    drawerState: DrawerState,
+    content: @Composable (DrawerPredictiveBackState) -> Unit
+) {
+    val drawerPredictiveBackState = remember { DrawerPredictiveBackState() }
+    val scope = rememberCoroutineScope()
+    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+    val maxScaleXDistanceGrow: Float
+    val maxScaleXDistanceShrink: Float
+    val maxScaleYDistance: Float
+    with(LocalDensity.current) {
+        maxScaleXDistanceGrow = PredictiveBackDrawerMaxScaleXDistanceGrow.toPx()
+        maxScaleXDistanceShrink = PredictiveBackDrawerMaxScaleXDistanceShrink.toPx()
+        maxScaleYDistance = PredictiveBackDrawerMaxScaleYDistance.toPx()
+    }
+
+    PredictiveBackHandler(enabled = drawerState.isOpen) { progress ->
+        try {
+            progress.collect { backEvent ->
+                drawerPredictiveBackState.update(
+                    PredictiveBack.transform(backEvent.progress),
+                    backEvent.swipeEdge == BackEventCompat.EDGE_LEFT,
+                    isRtl,
+                    maxScaleXDistanceGrow,
+                    maxScaleXDistanceShrink,
+                    maxScaleYDistance
+                )
+            }
+        } catch (e: CancellationException) {
+            drawerPredictiveBackState.clear()
+        } finally {
+            if (drawerPredictiveBackState.swipeEdgeMatchesDrawer) {
+                // If swipe edge matches drawer gravity and we've stretched the drawer horizontally,
+                // un-stretch it smoothly so that it hides completely during the drawer close.
+                scope.launch {
+                    animate(
+                        initialValue = drawerPredictiveBackState.scaleXDistance,
+                        targetValue = 0f
+                    ) { value, _ -> drawerPredictiveBackState.scaleXDistance = value }
+                    drawerPredictiveBackState.clear()
+                }
+            }
+            drawerState.close()
+        }
+    }
+
+    LaunchedEffect(drawerState.isClosed) {
+        if (drawerState.isClosed) {
+            drawerPredictiveBackState.clear()
+        }
+    }
+
+    content(drawerPredictiveBackState)
+}
+
+internal val PredictiveBackDrawerMaxScaleXDistanceGrow = 12.dp
+internal val PredictiveBackDrawerMaxScaleXDistanceShrink = 24.dp
+internal val PredictiveBackDrawerMaxScaleYDistance = 48.dp
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
index 9e32c8e..a6b9613 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/KeylineSnapPositionTest.kt
@@ -97,7 +97,12 @@
             add(xSmallSize, isAnchor = true)
         }
 
-        return Strategy { _, _ -> keylineList }.apply(availableSpace = 1000f, itemSpacing = 0f)
+        return Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 1000f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
     }
 
     // Test strategy that is start aligned:
@@ -127,7 +132,12 @@
             add(smallSize)
             add(xSmallSize, isAnchor = true)
         }
-        return Strategy { _, _ -> keylineList }.apply(availableSpace = 1000f, itemSpacing = 0f)
+        return Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 1000f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
     }
 
     // Test strategy that is start aligned:
@@ -150,6 +160,11 @@
             add(smallSize)
             add(xSmallSize, isAnchor = true)
         }
-        return Strategy { _, _ -> keylineList }.apply(1000f, 0f)
+        return Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 1000f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
     }
 }
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
index 84738c7..830559f 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/MultiBrowseTest.kt
@@ -39,7 +39,12 @@
             itemSpacing = 0f,
             itemCount = 10,
         )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(500f, 0f)
+        val strategy = Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 500f,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy.itemMainAxisSize).isEqualTo(itemSize)
     }
@@ -53,10 +58,12 @@
             preferredItemSize = itemSize,
             itemSpacing = 0f,
             itemCount = 10,
-        )!!
+            )!!
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = 100f,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val minSmallItemSize: Float = with(Density) { CarouselDefaults.MinSmallItemSize.toPx() }
         val keylines = strategy.defaultKeylines
@@ -80,10 +87,12 @@
             preferredItemSize = 200f,
             itemSpacing = 0f,
             itemCount = 10,
-        )!!
+            )!!
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = minSmallItemSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
 
@@ -118,7 +127,9 @@
             )!!
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = carouselSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
 
@@ -144,7 +155,12 @@
             itemSpacing = 0f,
             itemCount = 3,
         )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(carouselSize, 0f)
+        val strategy = Strategy { _, _ -> keylineList }.apply(
+            availableSpace = carouselSize,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
         val keylines = strategy.defaultKeylines
 
         // We originally expect a keyline list of [xSmall-Large-Large-Medium-Small-xSmall], but with
@@ -155,6 +171,7 @@
         assertThat(keylines[3].size).isLessThan(keylines[2].size)
     }
 
+    @Test
     fun testMultiBrowse_adjustsForItemSpacing() {
         val keylineList = multiBrowseKeylineList(
             density = Density,
@@ -163,7 +180,12 @@
             itemSpacing = 8f,
             itemCount = 10
         )!!
-        val strategy = Strategy { _, _ -> keylineList }.apply(380f, 8f)
+        val strategy = Strategy { _, _ -> keylineList }.apply(
+            availableSpace = 380f,
+            itemSpacing = 8f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(keylineList.firstFocal.size).isEqualTo(186f)
         // Ensure the first visible item is large and aligned with the start of the container
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
index 06052ff..595e34c 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/StrategyTest.kt
@@ -36,7 +36,9 @@
 
         val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
             availableSpace = carouselMainAxisSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
 
         assertThat(strategy.getKeylineListForScrollOffset(0f, maxScrollOffset))
@@ -67,7 +69,9 @@
 
         val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
             availableSpace = carouselMainAxisSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val endKeylineList = strategy.getEndKeylines()
 
@@ -89,7 +93,9 @@
 
         val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
             availableSpace = carouselMainAxisSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val startKeylineList = strategy.getStartKeylines()
 
@@ -112,7 +118,9 @@
 
         val strategy = Strategy { _, _ -> defaultKeylines }.apply(
             availableSpace = carouselMainAxisSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
 
         val startSteps = listOf(
@@ -219,7 +227,9 @@
 
         val strategy = Strategy { _, _ -> defaultKeylines }.apply(
             availableSpace = carouselMainAxisSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
 
         val endSteps = listOf(
@@ -354,8 +364,18 @@
         val strategy2 = Strategy { availableSpace, itemSpacingPx ->
             multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
         }
-        strategy1.apply(availableSpace = 500f, itemSpacing = itemSpacing)
-        strategy2.apply(availableSpace = 500f, itemSpacing = itemSpacing)
+        strategy1.apply(
+            availableSpace = 500f,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
+        strategy2.apply(
+            availableSpace = 500f,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy1 == strategy2).isTrue()
         assertThat(strategy1.hashCode()).isEqualTo(strategy2.hashCode())
@@ -372,8 +392,18 @@
         val strategy2 = Strategy { availableSpace, itemSpacingPx ->
             multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
         }
-        strategy1.apply(availableSpace = 500f, itemSpacing = itemSpacing)
-        strategy2.apply(availableSpace = 500f + 1f, itemSpacing = itemSpacing)
+        strategy1.apply(
+            availableSpace = 500f,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
+        strategy2.apply(
+            availableSpace = 500f + 1f,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy1 == strategy2).isFalse()
         assertThat(strategy1.hashCode()).isNotEqualTo(strategy2.hashCode())
@@ -390,7 +420,12 @@
         val strategy2 = Strategy { availableSpace, itemSpacingPx ->
             multiBrowseKeylineList(Density, availableSpace, itemSize, itemSpacingPx, itemCount)
         }
-        strategy1.apply(availableSpace = 500f, itemSpacing = itemSpacing)
+        strategy1.apply(
+            availableSpace = 500f,
+            itemSpacing = itemSpacing,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy1 == strategy2).isFalse()
         assertThat(strategy1.hashCode()).isNotEqualTo(strategy2.hashCode())
@@ -403,7 +438,12 @@
         val maxScrollOffset = (itemCount * large) - carouselMainAxisSize
         val defaultKeylineList = createStartAlignedKeylineList()
 
-        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(carouselMainAxisSize, 0f)
+        val strategy = Strategy { _, _ -> defaultKeylineList }.apply(
+            availableSpace = carouselMainAxisSize,
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy.getKeylineListForScrollOffset(0f, maxScrollOffset))
             .isEqualTo(defaultKeylineList)
@@ -419,7 +459,12 @@
                 add(56f)
                 add(10f, isAnchor = true)
             }
-        }.apply(availableSpace = 380f, itemSpacing = 8f)
+        }.apply(
+            availableSpace = 380f,
+            itemSpacing = 8f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         val middleStep = strategy.endKeylineSteps[1]
         val actualMiddleOffsets = middleStep.map { it.offset }.toFloatArray()
@@ -453,7 +498,12 @@
                 add(56f)
                 add(10f, isAnchor = true)
             }
-        }.apply(availableSpace = 768f, itemSpacing = 8f)
+        }.apply(
+            availableSpace = 768f,
+            itemSpacing = 8f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
+        )
 
         assertThat(strategy.startKeylineSteps).hasSize(3)
         assertThat(strategy.endKeylineSteps).hasSize(3)
@@ -507,6 +557,38 @@
         assertThat(e2ActualUnadjustedOffsets).isEqualTo(e2ExpectedUnadjustedOffsets)
     }
 
+    @Test
+    fun testCenterStrategy_stepsShouldAccountForContentPadding() {
+        val strategy = Strategy { availableSpace, itemSpacing ->
+            keylineListOf(availableSpace, itemSpacing, CarouselAlignment.Center) {
+                add(10f, isAnchor = true)
+                add(50f)
+                add(100f)
+                add(200f)
+                add(100f)
+                add(50f)
+                add(10f, isAnchor = true)
+            }
+        }.apply(500f, 0f, 16f, 24f)
+
+        val lastStartStep = strategy.startKeylineSteps.last()
+
+        val firstFocalLeft = lastStartStep.firstFocal.offset -
+            (lastStartStep.firstFocal.size / 2f)
+        val lastNonAnchorRight = lastStartStep.lastNonAnchor.offset +
+            (lastStartStep.lastNonAnchor.size / 2f)
+        val lastEndStep = strategy.endKeylineSteps.last()
+        val lastFocalRight = lastEndStep.lastFocal.offset +
+            (lastEndStep.lastFocal.size / 2f)
+        val firstNonAnchorLeft = lastEndStep.firstNonAnchor.offset -
+            (lastEndStep.firstNonAnchor.size / 2f)
+
+        assertThat(firstFocalLeft).isEqualTo(16f)
+        assertThat(lastNonAnchorRight).isEqualTo(500f)
+        assertThat(lastFocalRight).isEqualTo(500f - 24f)
+        assertThat(firstNonAnchorLeft).isWithin(.01f).of(0f)
+    }
+
     private fun assertEqualWithFloatTolerance(
         tolerance: Float,
         actual: Keyline,
diff --git a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
index 9f7ba239..cf6aa49 100644
--- a/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
+++ b/compose/material3/material3/src/androidUnitTest/kotlin/androidx/compose/material3/carousel/UncontainedTest.kt
@@ -41,7 +41,9 @@
         )
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = carouselSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
         val anchorSize = with(Density) { CarouselDefaults.AnchorSize.toPx() }
@@ -67,7 +69,9 @@
         )
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = carouselSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
         val anchorSize = with(Density) { CarouselDefaults.AnchorSize.toPx() }
@@ -96,7 +100,9 @@
         )
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = carouselSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
         val rightAnchorSize = with(Density) { CarouselDefaults.AnchorSize.toPx() }
@@ -134,7 +140,9 @@
         )
         val strategy = Strategy { _, _ -> keylineList }.apply(
             availableSpace = carouselSize,
-            itemSpacing = 0f
+            itemSpacing = 0f,
+            beforeContentPadding = 0f,
+            afterContentPadding = 0f
         )
         val keylines = strategy.defaultKeylines
         val rightAnchorSize = with(Density) { CarouselDefaults.AnchorSize.toPx() }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
index ca40239..24bdd24 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/NavigationDrawer.kt
@@ -51,6 +51,7 @@
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
@@ -61,8 +62,11 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.GraphicsLayerScope
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.platform.LocalDensity
@@ -79,6 +83,7 @@
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.lerp
 import kotlin.math.roundToInt
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.launch
@@ -510,6 +515,10 @@
 
 /**
  * Content inside of a modal navigation drawer.
+
+ * Note: This version of [ModalDrawerSheet] does not handle back by default. For automatic back
+ * handling and predictive back animations on Android 14+, use the [ModalDrawerSheet] that accepts
+ * `drawerState` as a param.
  *
  * @param modifier the [Modifier] to be applied to this drawer's content
  * @param drawerShape defines the shape of this drawer's container
@@ -535,6 +544,7 @@
     content: @Composable ColumnScope.() -> Unit
 ) {
     DrawerSheet(
+        drawerPredictiveBackState = null,
         windowInsets,
         modifier,
         drawerShape,
@@ -546,8 +556,58 @@
 }
 
 /**
+ * Content inside of a modal navigation drawer.
+ *
+ * Note: This version of [ModalDrawerSheet] requires a [drawerState] to be provided and will handle
+ * back by default for all Android versions, as well as animate during predictive back on Android
+ * 14+.
+ *
+ * @param drawerState state of the drawer
+ * @param modifier the [Modifier] to be applied to this drawer's content
+ * @param drawerShape defines the shape of this drawer's container
+ * @param drawerContainerColor the color used for the background of this drawer. Use
+ * [Color.Transparent] to have no color.
+ * @param drawerContentColor the preferred color for content inside this drawer. Defaults to either
+ * the matching content color for [drawerContainerColor], or to the current [LocalContentColor] if
+ * [drawerContainerColor] is not a color from the theme.
+ * @param drawerTonalElevation when [drawerContainerColor] is [ColorScheme.surface], a translucent
+ * primary color overlay is applied on top of the container. A higher tonal elevation value will
+ * result in a darker color in light theme and lighter color in dark theme. See also: [Surface].
+ * @param windowInsets a window insets for the sheet.
+ * @param content content inside of a modal navigation drawer
+ */
+@Composable
+fun ModalDrawerSheet(
+    drawerState: DrawerState,
+    modifier: Modifier = Modifier,
+    drawerShape: Shape = DrawerDefaults.shape,
+    drawerContainerColor: Color = DrawerDefaults.modalContainerColor,
+    drawerContentColor: Color = contentColorFor(drawerContainerColor),
+    drawerTonalElevation: Dp = DrawerDefaults.ModalDrawerElevation,
+    windowInsets: WindowInsets = DrawerDefaults.windowInsets,
+    content: @Composable ColumnScope.() -> Unit
+) {
+    DrawerPredictiveBackHandler(drawerState) { drawerPredictiveBackState ->
+        DrawerSheet(
+            drawerPredictiveBackState,
+            windowInsets,
+            modifier,
+            drawerShape,
+            drawerContainerColor,
+            drawerContentColor,
+            drawerTonalElevation,
+            content
+        )
+    }
+}
+
+/**
  * Content inside of a dismissible navigation drawer.
  *
+ * Note: This version of [DismissibleDrawerSheet] does not handle back by default. For automatic
+ * back handling and predictive back animations on Android 14+, use the [DismissibleDrawerSheet]
+ * that accepts `drawerState` as a param.
+ *
  * @param modifier the [Modifier] to be applied to this drawer's content
  * @param drawerShape defines the shape of this drawer's container
  * @param drawerContainerColor the color used for the background of this drawer. Use
@@ -572,6 +632,7 @@
     content: @Composable ColumnScope.() -> Unit
 ) {
     DrawerSheet(
+        drawerPredictiveBackState = null,
         windowInsets,
         modifier,
         drawerShape,
@@ -583,6 +644,52 @@
 }
 
 /**
+ * Content inside of a dismissible navigation drawer.
+
+ * Note: This version of [DismissibleDrawerSheet] requires a [drawerState] to be provided and will
+ * handle back by default for all Android versions, as well as animate during predictive back on
+ * Android 14+.
+ *
+ * @param drawerState state of the drawer
+ * @param modifier the [Modifier] to be applied to this drawer's content
+ * @param drawerShape defines the shape of this drawer's container
+ * @param drawerContainerColor the color used for the background of this drawer. Use
+ * [Color.Transparent] to have no color.
+ * @param drawerContentColor the preferred color for content inside this drawer. Defaults to either
+ * the matching content color for [drawerContainerColor], or to the current [LocalContentColor] if
+ * [drawerContainerColor] is not a color from the theme.
+ * @param drawerTonalElevation when [drawerContainerColor] is [ColorScheme.surface], a translucent
+ * primary color overlay is applied on top of the container. A higher tonal elevation value will
+ * result in a darker color in light theme and lighter color in dark theme. See also: [Surface].
+ * @param windowInsets a window insets for the sheet.
+ * @param content content inside of a dismissible navigation drawer
+ */
+@Composable
+fun DismissibleDrawerSheet(
+    drawerState: DrawerState,
+    modifier: Modifier = Modifier,
+    drawerShape: Shape = RectangleShape,
+    drawerContainerColor: Color = DrawerDefaults.standardContainerColor,
+    drawerContentColor: Color = contentColorFor(drawerContainerColor),
+    drawerTonalElevation: Dp = DrawerDefaults.DismissibleDrawerElevation,
+    windowInsets: WindowInsets = DrawerDefaults.windowInsets,
+    content: @Composable ColumnScope.() -> Unit
+) {
+    DrawerPredictiveBackHandler(drawerState) { drawerPredictiveBackState ->
+        DrawerSheet(
+            drawerPredictiveBackState,
+            windowInsets,
+            modifier,
+            drawerShape,
+            drawerContainerColor,
+            drawerContentColor,
+            drawerTonalElevation,
+            content
+        )
+    }
+}
+
+/**
  * Content inside of a permanent navigation drawer.
  *
  * @param modifier the [Modifier] to be applied to this drawer's content
@@ -610,6 +717,7 @@
 ) {
     val navigationMenu = getString(Strings.NavigationMenu)
     DrawerSheet(
+        drawerPredictiveBackState = null,
         windowInsets,
         modifier.semantics {
             paneTitle = navigationMenu
@@ -623,7 +731,8 @@
 }
 
 @Composable
-private fun DrawerSheet(
+internal fun DrawerSheet(
+    drawerPredictiveBackState: DrawerPredictiveBackState?,
     windowInsets: WindowInsets,
     modifier: Modifier = Modifier,
     drawerShape: Shape = RectangleShape,
@@ -632,30 +741,93 @@
     drawerTonalElevation: Dp = DrawerDefaults.PermanentDrawerElevation,
     content: @Composable ColumnScope.() -> Unit
 ) {
+    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+    val predictiveBackDrawerContainerModifier =
+        if (drawerPredictiveBackState != null) Modifier.predictiveBackDrawerContainer(
+            drawerPredictiveBackState,
+            isRtl
+        ) else Modifier
     Surface(
         modifier = modifier
             .sizeIn(
                 minWidth = MinimumDrawerWidth,
                 maxWidth = DrawerDefaults.MaximumDrawerWidth
             )
+            .then(predictiveBackDrawerContainerModifier)
             .fillMaxHeight(),
         shape = drawerShape,
         color = drawerContainerColor,
         contentColor = drawerContentColor,
         tonalElevation = drawerTonalElevation
     ) {
+        val predictiveBackDrawerChildModifier =
+            if (drawerPredictiveBackState != null) Modifier.predictiveBackDrawerChild(
+                drawerPredictiveBackState,
+                isRtl
+            ) else Modifier
         Column(
             Modifier
                 .sizeIn(
                     minWidth = MinimumDrawerWidth,
                     maxWidth = DrawerDefaults.MaximumDrawerWidth
                 )
+                .then(predictiveBackDrawerChildModifier)
                 .windowInsetsPadding(windowInsets),
             content = content
         )
     }
 }
 
+private fun Modifier.predictiveBackDrawerContainer(
+    drawerPredictiveBackState: DrawerPredictiveBackState,
+    isRtl: Boolean
+) = graphicsLayer {
+    scaleX = calculatePredictiveBackScaleX(drawerPredictiveBackState)
+    scaleY = calculatePredictiveBackScaleY(drawerPredictiveBackState)
+    transformOrigin = TransformOrigin(if (isRtl) 1f else 0f, 0.5f)
+}
+
+private fun Modifier.predictiveBackDrawerChild(
+    drawerPredictiveBackState: DrawerPredictiveBackState,
+    isRtl: Boolean
+) = graphicsLayer {
+    // Preserve the original aspect ratio and container alignment of the child
+    // content, and add content margins.
+    val containerScaleX = calculatePredictiveBackScaleX(drawerPredictiveBackState)
+    val containerScaleY = calculatePredictiveBackScaleY(drawerPredictiveBackState)
+    scaleX = if (containerScaleX != 0f) containerScaleY / containerScaleX else 1f
+    transformOrigin = TransformOrigin(if (isRtl) 0f else 1f, 0f)
+}
+
+private fun GraphicsLayerScope.calculatePredictiveBackScaleX(
+    drawerPredictiveBackState: DrawerPredictiveBackState
+): Float {
+    val width = size.width
+    return if (width.isNaN() || width == 0f) {
+        1f
+    } else {
+        val scaleXDirection = if (drawerPredictiveBackState.swipeEdgeMatchesDrawer) 1 else -1
+        1f + drawerPredictiveBackState.scaleXDistance * scaleXDirection / width
+    }
+}
+
+private fun GraphicsLayerScope.calculatePredictiveBackScaleY(
+    drawerPredictiveBackState: DrawerPredictiveBackState
+): Float {
+    val height = size.height
+    return if (height.isNaN() || height == 0f) {
+        1f
+    } else {
+        1f - drawerPredictiveBackState.scaleYDistance / height
+    }
+}
+
+@Composable
+internal expect fun DrawerPredictiveBackHandler(
+    drawerState: DrawerState,
+    content: @Composable (DrawerPredictiveBackState) -> Unit
+)
+
 /**
  * Object to hold default values for [ModalNavigationDrawer]
  */
@@ -861,6 +1033,36 @@
     val ItemPadding = PaddingValues(horizontal = 12.dp)
 }
 
+@Stable
+internal class DrawerPredictiveBackState {
+
+    var swipeEdgeMatchesDrawer by mutableStateOf(true)
+
+    var scaleXDistance by mutableFloatStateOf(0f)
+
+    var scaleYDistance by mutableFloatStateOf(0f)
+
+    fun update(
+        progress: Float,
+        swipeEdgeLeft: Boolean,
+        isRtl: Boolean,
+        maxScaleXDistanceGrow: Float,
+        maxScaleXDistanceShrink: Float,
+        maxScaleYDistance: Float
+    ) {
+        swipeEdgeMatchesDrawer = swipeEdgeLeft != isRtl
+        val maxScaleXDistance =
+            if (swipeEdgeMatchesDrawer) maxScaleXDistanceGrow else maxScaleXDistanceShrink
+        scaleXDistance = lerp(0f, maxScaleXDistance, progress)
+        scaleYDistance = lerp(0f, maxScaleYDistance, progress)
+    }
+    fun clear() {
+        swipeEdgeMatchesDrawer = true
+        scaleXDistance = 0f
+        scaleYDistance = 0f
+    }
+}
+
 private class DefaultDrawerItemsColor(
     val selectedIconColor: Color,
     val unselectedIconColor: Color,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
index 1a41903..b81a1fe 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
@@ -29,6 +29,9 @@
 import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
 import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.PageSize
 import androidx.compose.foundation.pager.PagerDefaults
@@ -65,18 +68,24 @@
  * A horizontal carousel meant to display many items at once for quick browsing of smaller content
  * like album art or photo thumbnails.
  *
- * Note that this carousel may adjust the size of large items. In order to ensure a mix of large,
+ * Note that this carousel may adjust the size of items in order to ensure a mix of large,
  * medium, and small items fit perfectly into the available space and are arranged in a
- * visually pleasing way, this carousel finds the nearest number of large items that
- * will fit the container and adjusts their size to fit, if necessary.
+ * visually pleasing way. Carousel then lays out items using the large item size and clips
+ * (or masks) items depending on their scroll offset to create items which smoothly expand
+ * and collapse between the large, medium, and small sizes.
  *
  * For more information, see <a href="https://material.io/components/carousel/overview">design
  * guidelines</a>.
  *
+ * Example of a multi-browse carousel:
+ * @sample androidx.compose.material3.samples.HorizontalMultiBrowseCarouselSample
+ *
  * @param state The state object to be used to control the carousel's state
- * @param preferredItemWidth The width the fully visible items would like to be in the main axis.
- * This width is a target and will likely be adjusted by carousel in order to fit a whole number of
- * items within the container
+ * @param preferredItemWidth The width that large, fully visible items would like to be in the
+ * horizontal axis. This width is a target and will likely be adjusted by carousel in order to fit
+ * a whole number of items within the container. Carousel adjusts small items first (between the
+ * [minSmallItemWidth] and [maxSmallItemWidth]) then medium items when present, and finally large
+ * items if necessary.
  * @param modifier A modifier instance to be applied to this carousel container
  * @param itemSpacing The amount of space used to separate items in the carousel
  * @param flingBehavior The [TargetedFlingBehavior] to be used for post scroll gestures
@@ -86,13 +95,14 @@
  * @param maxSmallItemWidth The maximum allowable width of small items in dp. Depending on the
  * [preferredItemWidth] and the width of the carousel, the small item width will be chosen from a
  * range of [minSmallItemWidth] and [maxSmallItemWidth]
+ * @param contentPadding a padding around the whole content. This will add padding for the
+ * content after it has been clipped. You can use it to add a padding before the first item or
+ * after the last one. Use [itemSpacing] to add spacing between the items.
  * @param content The carousel's content Composable
- *
- * TODO: Add sample link
  */
 @ExperimentalMaterial3Api
 @Composable
-internal fun HorizontalMultiBrowseCarousel(
+fun HorizontalMultiBrowseCarousel(
     state: CarouselState,
     preferredItemWidth: Dp,
     modifier: Modifier = Modifier,
@@ -101,6 +111,7 @@
         CarouselDefaults.singleAdvanceFlingBehavior(state = state),
     minSmallItemWidth: Dp = CarouselDefaults.MinSmallItemSize,
     maxSmallItemWidth: Dp = CarouselDefaults.MaxSmallItemSize,
+    contentPadding: PaddingValues = PaddingValues(0.dp),
     content: @Composable CarouselScope.(itemIndex: Int) -> Unit
 ) {
     val density = LocalDensity.current
@@ -120,6 +131,7 @@
                 )
             }
         },
+        contentPadding = contentPadding,
         modifier = modifier,
         itemSpacing = itemSpacing,
         flingBehavior = flingBehavior,
@@ -140,23 +152,28 @@
  * For more information, see <a href="https://material.io/components/carousel/overview">design
  * guidelines</a>.
  *
+ * Example of an uncontained carousel:
+ * @sample androidx.compose.material3.samples.HorizontalUncontainedCarouselSample
+ *
  * @param state The state object to be used to control the carousel's state
  * @param itemWidth The width of items in the carousel
  * @param modifier A modifier instance to be applied to this carousel container
  * @param itemSpacing The amount of space used to separate items in the carousel
  * @param flingBehavior The [TargetedFlingBehavior] to be used for post scroll gestures
+ * @param contentPadding a padding around the whole content. This will add padding for the
+ * content after it has been clipped. You can use it to add a padding before the first item or
+ * after the last one. Use [itemSpacing] to add spacing between the items.
  * @param content The carousel's content Composable
- *
- * TODO: Add sample link
  */
 @ExperimentalMaterial3Api
 @Composable
-internal fun HorizontalUncontainedCarousel(
+fun HorizontalUncontainedCarousel(
     state: CarouselState,
     itemWidth: Dp,
     modifier: Modifier = Modifier,
     itemSpacing: Dp = 0.dp,
     flingBehavior: TargetedFlingBehavior = CarouselDefaults.noSnapFlingBehavior(),
+    contentPadding: PaddingValues = PaddingValues(0.dp),
     content: @Composable CarouselScope.(itemIndex: Int) -> Unit
 ) {
     val density = LocalDensity.current
@@ -173,6 +190,7 @@
                 )
             }
         },
+        contentPadding = contentPadding,
         modifier = modifier,
         itemSpacing = itemSpacing,
         flingBehavior = flingBehavior,
@@ -190,19 +208,22 @@
  * @param orientation The layout orientation of the carousel
  * @param keylineList The list of keylines that are fixed positions along the scrolling axis which
  * define the state an item should be in when its center is co-located with the keyline's position.
+ * @param contentPadding a padding around the whole content. This will add padding for the
  * @param modifier A modifier instance to be applied to this carousel outer layout
+ * content after it has been clipped. You can use it to add a padding before the first item or
+ * after the last one. Use [itemSpacing] to add spacing between the items.
  * @param itemSpacing The amount of space used to separate items in the carousel
  * @param flingBehavior The [TargetedFlingBehavior] to be used for post scroll gestures
  * @param content The carousel's content Composable where each call is passed the index, from the
  * total item count, of the item being composed
- * TODO: Add sample link
  */
-@ExperimentalMaterial3Api
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 internal fun Carousel(
     state: CarouselState,
     orientation: Orientation,
     keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?,
+    contentPadding: PaddingValues,
     modifier: Modifier = Modifier,
     itemSpacing: Dp = 0.dp,
     flingBehavior: TargetedFlingBehavior =
@@ -210,7 +231,11 @@
     content: @Composable CarouselScope.(itemIndex: Int) -> Unit
 ) {
     val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
-    val pageSize = remember(keylineList) { CarouselPageSize(keylineList) }
+    val beforeContentPadding = contentPadding.calculateBeforeContentPadding(orientation)
+    val afterContentPadding = contentPadding.calculateAfterContentPadding(orientation)
+    val pageSize = remember(keylineList) {
+        CarouselPageSize(keylineList, beforeContentPadding, afterContentPadding)
+    }
 
     val outOfBoundsPageCount = remember(pageSize.strategy.itemMainAxisSize) {
         calculateOutOfBounds(pageSize.strategy)
@@ -228,6 +253,11 @@
     if (orientation == Orientation.Horizontal) {
         HorizontalPager(
             state = state.pagerState,
+            // Only pass cross axis padding as main axis padding will be handled by the strategy
+            contentPadding = PaddingValues(
+                top = contentPadding.calculateTopPadding(),
+                bottom = contentPadding.calculateBottomPadding()
+            ),
             pageSize = pageSize,
             pageSpacing = itemSpacing,
             outOfBoundsPageCount = outOfBoundsPageCount,
@@ -250,6 +280,11 @@
     } else if (orientation == Orientation.Vertical) {
         VerticalPager(
             state = state.pagerState,
+            // Only pass cross axis padding as main axis padding will be handled by the strategy
+            contentPadding = PaddingValues(
+                start = contentPadding.calculateStartPadding(LocalLayoutDirection.current),
+                end = contentPadding.calculateEndPadding(LocalLayoutDirection.current)
+            ),
             pageSize = pageSize,
             pageSpacing = itemSpacing,
             outOfBoundsPageCount = outOfBoundsPageCount,
@@ -272,6 +307,28 @@
     }
 }
 
+@Composable
+private fun PaddingValues.calculateBeforeContentPadding(orientation: Orientation): Float {
+    val dpValue = if (orientation == Orientation.Vertical) {
+        calculateTopPadding()
+    } else {
+        calculateStartPadding(LocalLayoutDirection.current)
+    }
+
+    return with(LocalDensity.current) { dpValue.toPx() }
+}
+
+@Composable
+private fun PaddingValues.calculateAfterContentPadding(orientation: Orientation): Float {
+    val dpValue = if (orientation == Orientation.Vertical) {
+        calculateBottomPadding()
+    } else {
+        calculateEndPadding(LocalLayoutDirection.current)
+    }
+
+    return with(LocalDensity.current) { dpValue.toPx() }
+}
+
 internal fun calculateOutOfBounds(strategy: Strategy): Int {
     if (!strategy.isValid()) {
         return PagerDefaults.OutOfBoundsPageCount
@@ -297,11 +354,18 @@
  * define the state an item should be in when its center is co-located with the keyline's position.
  */
 private class CarouselPageSize(
-    keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?
+    keylineList: (availableSpace: Float, itemSpacing: Float) -> KeylineList?,
+    private val beforeContentPadding: Float,
+    private val afterContentPadding: Float
 ) : PageSize {
     val strategy = Strategy(keylineList)
     override fun Density.calculateMainAxisPageSize(availableSpace: Int, pageSpacing: Int): Int {
-        strategy.apply(availableSpace.toFloat(), pageSpacing.toFloat())
+        strategy.apply(
+            availableSpace.toFloat(),
+            pageSpacing.toFloat(),
+            beforeContentPadding,
+            afterContentPadding
+        )
         return if (strategy.isValid()) {
             strategy.itemMainAxisSize.roundToInt()
         } else {
@@ -337,7 +401,7 @@
  * @param state the carousel state
  * @param strategy the strategy used to mask and translate items in the carousel
  * @param itemPositionMap the position of each index when it is the current item
- * @param isRtl whether or not the carousel is rtl
+ * @param isRtl true if the layout direction is right-to-left
  */
 @OptIn(ExperimentalMaterial3Api::class)
 internal fun Modifier.carouselItem(
@@ -503,12 +567,7 @@
  * Contains the default values used by [Carousel].
  */
 @ExperimentalMaterial3Api
-internal object CarouselDefaults {
-    /** The minimum size that a carousel strategy can choose its small items to be. **/
-    val MinSmallItemSize = 40.dp
-
-    /** The maximum size that a carousel strategy can choose its small items to be. **/
-    val MaxSmallItemSize = 56.dp
+object CarouselDefaults {
 
     /**
      * A [TargetedFlingBehavior] that limits a fling to one item at a time. [snapAnimationSpec] can
@@ -602,6 +661,12 @@
         return rememberSnapFlingBehavior(snapLayoutInfoProvider = decayLayoutInfoProvider)
     }
 
+    /** The minimum size that a carousel strategy can choose its small items to be. **/
+    internal val MinSmallItemSize = 40.dp
+
+    /** The maximum size that a carousel strategy can choose its small items to be. **/
+    internal val MaxSmallItemSize = 56.dp
+
     internal val AnchorSize = 10.dp
     internal const val MediumLargeItemDiffThreshold = 0.85f
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselScope.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselScope.kt
index 94dbda2..467d8a5 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselScope.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselScope.kt
@@ -22,7 +22,7 @@
  * Receiver scope for [Carousel].
  */
 @ExperimentalMaterial3Api
-internal sealed interface CarouselScope
+sealed interface CarouselScope
 
 @ExperimentalMaterial3Api
 internal object CarouselScopeImpl : CarouselScope
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
index b6d989a..1046082 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/CarouselState.kt
@@ -16,7 +16,7 @@
 
 package androidx.compose.material3.carousel
 
-import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.annotation.FloatRange
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.gestures.ScrollScope
 import androidx.compose.foundation.gestures.ScrollableState
@@ -32,14 +32,15 @@
  * The state that can be used to control all types of carousels.
  *
  * @param currentItem the current item to be scrolled to.
- * @param currentItemOffsetFraction the current item offset as a fraction of the item size.
+ * @param currentItemOffsetFraction the offset of the current item as a fraction of the item's size.
+ * This should vary between -0.5 and 0.5 and indicates how to offset the current item from the
+ * snapped position.
  * @param itemCount the number of items this Carousel will have.
  */
-@OptIn(ExperimentalFoundationApi::class)
 @ExperimentalMaterial3Api
-internal class CarouselState(
+class CarouselState(
     currentItem: Int = 0,
-    currentItemOffsetFraction: Float = 0F,
+    @FloatRange(from = -0.5, to = 0.5) currentItemOffsetFraction: Float = 0f,
     itemCount: () -> Int
 ) : ScrollableState {
     var itemCountState = mutableStateOf(itemCount)
@@ -92,7 +93,7 @@
  */
 @ExperimentalMaterial3Api
 @Composable
-internal fun rememberCarouselState(
+fun rememberCarouselState(
     initialItem: Int = 0,
     itemCount: () -> Int,
 ): CarouselState {
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
index c8a7af0..9caf196 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
@@ -44,21 +44,21 @@
     val endStepsSize = endKeylineSteps.size + numOfFocalKeylines
 
     for (itemIndex in 0 until itemCount) {
-        map[itemIndex] = (defaultKeylines.firstFocal.offset -
-            defaultKeylines.firstFocal.size / 2F).roundToInt()
+        map[itemIndex] = (defaultKeylines.firstFocal.unadjustedOffset -
+            strategy.itemMainAxisSize / 2F).roundToInt()
         if (itemIndex < startStepsSize) {
             var startIndex = max(0, startStepsSize - 1 - itemIndex)
             startIndex = min(startKeylineSteps.size - 1, startIndex)
             val startKeylines = startKeylineSteps[startIndex]
-            map[itemIndex] = (startKeylines.firstFocal.offset -
-                startKeylines.firstFocal.size / 2f).roundToInt()
+            map[itemIndex] = (startKeylines.firstFocal.unadjustedOffset -
+                strategy.itemMainAxisSize / 2f).roundToInt()
         }
         if (itemCount > numOfFocalKeylines + 1 && itemIndex >= itemCount - endStepsSize) {
             var endIndex = max(0, itemIndex - itemCount + endStepsSize)
             endIndex = min(endKeylineSteps.size - 1, endIndex)
             val endKeylines = endKeylineSteps[endIndex]
-            map[itemIndex] = (endKeylines.firstFocal.offset -
-                endKeylines.firstFocal.size / 2f).roundToInt()
+            map[itemIndex] = (endKeylines.firstFocal.unadjustedOffset -
+                strategy.itemMainAxisSize / 2f).roundToInt()
         }
     }
     return map
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
index 9c654b2..6b47ff5 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
@@ -65,10 +65,7 @@
     // of the large item and medium items are sized between large and small items. Clamp the
     // small target size within our min-max range and as close to 1/3 of the target large item
     // size as possible.
-    val targetSmallSize: Float = (targetLargeSize / 3f + itemSpacing).coerceIn(
-        minSmallItemSize + itemSpacing,
-        maxSmallItemSize + itemSpacing
-    )
+    val targetSmallSize: Float = (targetLargeSize / 3f).coerceIn(minSmallItemSize, maxSmallItemSize)
     val targetMediumSize = (targetLargeSize + targetSmallSize) / 2f
 
     if (carouselMainAxisSize < minSmallItemSize * 2) {
@@ -108,15 +105,15 @@
         var mediumCount = arrangement.mediumCount
         while (keylineSurplus > 0) {
             if (smallCount > 0) {
-                smallCount -= 1;
+                smallCount -= 1
             } else if (mediumCount > 1) {
                 // Keep at least 1 medium so the large items don't fill the entire carousel in new
                 // strategy.
-                mediumCount -= 1;
+                mediumCount -= 1
             }
             // large items don't need to be removed even if they are a surplus because large items
             // are already fully unmasked.
-            keylineSurplus -= 1;
+            keylineSurplus -= 1
         }
         arrangement = Arrangement.findLowestCostArrangement(
             availableSpace = carouselMainAxisSize,
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
index 5641be1..916efe0 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
@@ -22,8 +22,11 @@
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.util.fastFilter
 import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMapIndexed
 import androidx.compose.ui.util.lerp
+import kotlin.math.abs
 import kotlin.math.max
 import kotlin.math.roundToInt
 
@@ -84,6 +87,8 @@
     internal var availableSpace: Float = 0f
     /** The spacing between each item. */
     internal var itemSpacing: Float = 0f
+    internal var beforeContentPadding: Float = 0f
+    internal var afterContentPadding: Float = 0f
     /** The size of items when in focus and fully unmasked. */
     internal var itemMainAxisSize by mutableFloatStateOf(0f)
 
@@ -101,8 +106,15 @@
      * This method must be called before a strategy can be used by carousel.
      *
      * @param availableSpace the size of the carousel container in scrolling axis
+     * @param beforeContentPadding the padding to add before the list content
+     * @param afterContentPadding the padding to add after the list content
      */
-    internal fun apply(availableSpace: Float, itemSpacing: Float): Strategy {
+    internal fun apply(
+        availableSpace: Float,
+        itemSpacing: Float,
+        beforeContentPadding: Float,
+        afterContentPadding: Float
+    ): Strategy {
         // Skip computing new keylines and updating this strategy if
         // available space has not changed.
         if (this.availableSpace == availableSpace && this.itemSpacing == itemSpacing) {
@@ -110,15 +122,16 @@
         }
 
         val keylineList = keylineList.invoke(availableSpace, itemSpacing) ?: return this
-        val startKeylineSteps = getStartKeylineSteps(keylineList, availableSpace, itemSpacing)
+        val startKeylineSteps =
+            getStartKeylineSteps(keylineList, availableSpace, itemSpacing, beforeContentPadding)
         val endKeylineSteps =
-            getEndKeylineSteps(keylineList, availableSpace, itemSpacing)
+            getEndKeylineSteps(keylineList, availableSpace, itemSpacing, afterContentPadding)
 
         // TODO: Update this to use the first/last focal keylines to calculate shift?
-        val startShiftDistance = startKeylineSteps.last().first().unadjustedOffset -
-            keylineList.first().unadjustedOffset
-        val endShiftDistance = keylineList.last().unadjustedOffset -
-            endKeylineSteps.last().last().unadjustedOffset
+        val startShiftDistance = max(startKeylineSteps.last().first().unadjustedOffset -
+            keylineList.first().unadjustedOffset, beforeContentPadding)
+        val endShiftDistance = max(keylineList.last().unadjustedOffset -
+            endKeylineSteps.last().last().unadjustedOffset, afterContentPadding)
 
         this.defaultKeylines = keylineList
         this.defaultKeylines = keylineList
@@ -138,6 +151,8 @@
         )
         this.availableSpace = availableSpace
         this.itemSpacing = itemSpacing
+        this.beforeContentPadding = beforeContentPadding
+        this.afterContentPadding = afterContentPadding
         this.itemMainAxisSize = defaultKeylines.firstFocal.size
 
         return this
@@ -229,6 +244,8 @@
         if (isValid() != other.isValid()) return false
         if (availableSpace != other.availableSpace) return false
         if (itemSpacing != other.itemSpacing) return false
+        if (beforeContentPadding != other.beforeContentPadding) return false
+        if (afterContentPadding != other.afterContentPadding) return false
         if (itemMainAxisSize != other.itemMainAxisSize) return false
         if (startShiftDistance != other.startShiftDistance) return false
         if (endShiftDistance != other.endShiftDistance) return false
@@ -247,6 +264,8 @@
         var result = isValid().hashCode()
         result = 31 * result + availableSpace.hashCode()
         result = 31 * result + itemSpacing.hashCode()
+        result = 31 * result + beforeContentPadding.hashCode()
+        result = 31 * result + afterContentPadding.hashCode()
         result = 31 * result + itemMainAxisSize.hashCode()
         result = 31 * result + startShiftDistance.hashCode()
         result = 31 * result + endShiftDistance.hashCode()
@@ -277,12 +296,23 @@
         private fun getStartKeylineSteps(
             defaultKeylines: KeylineList,
             carouselMainAxisSize: Float,
-            itemSpacing: Float
+            itemSpacing: Float,
+            beforeContentPadding: Float
         ): List<KeylineList> {
             val steps: MutableList<KeylineList> = mutableListOf()
             steps.add(defaultKeylines)
 
             if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
+                if (beforeContentPadding != 0f) {
+                    steps.add(
+                        createShiftedKeylineListForContentPadding(
+                            defaultKeylines,
+                            carouselMainAxisSize,
+                            itemSpacing,
+                            beforeContentPadding
+                        )
+                    )
+                }
                 return steps
             }
 
@@ -329,6 +359,15 @@
                 i++
             }
 
+            if (beforeContentPadding != 0f) {
+                steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
+                    steps.last(),
+                    carouselMainAxisSize,
+                    itemSpacing,
+                    beforeContentPadding
+                )
+            }
+
             return steps
         }
 
@@ -351,12 +390,21 @@
         private fun getEndKeylineSteps(
             defaultKeylines: KeylineList,
             carouselMainAxisSize: Float,
-            itemSpacing: Float
+            itemSpacing: Float,
+            afterContentPadding: Float
         ): List<KeylineList> {
             val steps: MutableList<KeylineList> = mutableListOf()
             steps.add(defaultKeylines)
 
             if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
+                if (afterContentPadding != 0f) {
+                    steps.add(createShiftedKeylineListForContentPadding(
+                        defaultKeylines,
+                        carouselMainAxisSize,
+                        itemSpacing,
+                        -afterContentPadding
+                    ))
+                }
                 return steps
             }
 
@@ -403,10 +451,54 @@
                 i++
             }
 
+            if (afterContentPadding != 0f) {
+                steps[steps.lastIndex] = createShiftedKeylineListForContentPadding(
+                    steps.last(),
+                    carouselMainAxisSize,
+                    itemSpacing,
+                    -afterContentPadding
+                )
+            }
+
             return steps
         }
 
         /**
+         * Returns a new [KeylineList] identical to [from] but with each keyline's offset shifted
+         * by [contentPadding].
+         */
+        private fun createShiftedKeylineListForContentPadding(
+            from: KeylineList,
+            carouselMainAxisSize: Float,
+            itemSpacing: Float,
+            contentPadding: Float
+        ): KeylineList {
+            val numberOfNonAnchorKeylines = from.fastFilter { !it.isAnchor }.count()
+            val sizeReduction = contentPadding / numberOfNonAnchorKeylines
+            // Let keylineListOf create a new keyline list with offsets adjusted for each item's
+            // reduction in size
+            val newKeylines = keylineListOf(
+                carouselMainAxisSize = carouselMainAxisSize,
+                itemSpacing = itemSpacing,
+                pivotIndex = from.pivotIndex,
+                pivotOffset = from.pivot.offset + contentPadding - (sizeReduction / 2f)
+            ) {
+                from.fastForEach { k -> add(k.size - abs(sizeReduction), k.isAnchor) }
+            }
+
+            // Then reset each item's unadjusted offset back to their original value from the
+            // incoming keyline list. This is necessary because Pager will still be laying out items
+            // end-to-end with the original page size and not the new reduced size.
+            return KeylineList(
+                newKeylines.fastMapIndexed { i, k ->
+                    k.copy(
+                        unadjustedOffset = from[i].unadjustedOffset
+                    )
+                }
+            )
+        }
+
+        /**
          * Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and
          * with updated pivot and offsets that reflect any change in focal shift.
          */
diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/NavigationDrawer.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/NavigationDrawer.desktop.kt
new file mode 100644
index 0000000..94baaa8
--- /dev/null
+++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/NavigationDrawer.desktop.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+/**
+ * A predictive back handler that does nothing when running in a desktop context, since predictive
+ * back is only supported on Android.
+ *
+ * @param drawerState state of the drawer
+ * @param content content of the rest of the UI
+ */
+@Composable
+internal actual fun DrawerPredictiveBackHandler(
+    drawerState: DrawerState,
+    content: @Composable (DrawerPredictiveBackState) -> Unit
+) {
+    content(remember { DrawerPredictiveBackState() })
+}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeVersion.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeVersion.kt
index 02f6601..d2c2079 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeVersion.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/ComposeVersion.kt
@@ -28,5 +28,5 @@
      * IMPORTANT: Whenever updating this value, please make sure to also update `versionTable` and
      * `minimumRuntimeVersionInt` in `VersionChecker.kt` of the compiler.
      */
-    const val version: Int = 12400
+    const val version: Int = 12500
 }
diff --git a/compose/ui/ui/proguard-rules.pro b/compose/ui/ui/proguard-rules.pro
index 1dbcbf7..7aa50f4 100644
--- a/compose/ui/ui/proguard-rules.pro
+++ b/compose/ui/ui/proguard-rules.pro
@@ -17,6 +17,7 @@
 # R8 to complain about them not being there during optimization.
 -dontwarn android.view.RenderNode
 -dontwarn android.view.DisplayListCanvas
+-dontwarn android.view.HardwareCanvas
 
 -keepclassmembers class androidx.compose.ui.platform.ViewLayerContainer {
     protected void dispatchGetDisplayList();
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
index 94119f3..b20ed840 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/AndroidPointerInputTest.kt
@@ -58,6 +58,7 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.OpenComposeView
+import androidx.compose.ui.background
 import androidx.compose.ui.composed
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.draw.scale
@@ -65,6 +66,7 @@
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.gesture.PointerCoords
 import androidx.compose.ui.gesture.PointerProperties
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.layout.LayoutCoordinates
@@ -2267,6 +2269,1050 @@
     }
 
     /*
+     * Tests TOUCH events are triggered correctly when dynamically adding a NON-pointer input
+     * modifier above an existing pointer input modifier.
+     *
+     * Note: The lambda for the existing pointer input modifier is not re-executed after the
+     * dynamic one is added.
+     *
+     * Specific events:
+     *  1. UI Element (modifier 1 only): PRESS (touch)
+     *  2. UI Element (modifier 1 only): MOVE (touch)
+     *  3. UI Element (modifier 1 only): RELEASE (touch)
+     *  4. Dynamically adds NON-pointer input modifier (between input event streams)
+     *  5. UI Element (modifier 1 and 2): PRESS (touch)
+     *  6. UI Element (modifier 1 and 2): MOVE (touch)
+     *  7. UI Element (modifier 1 and 2): RELEASE (touch)
+     */
+    @Test
+    fun dynamicNonInputModifier_addsAboveExistingModifier_shouldTriggerInNewModifier() {
+        // --> Arrange
+        val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
+        var box1LayoutCoordinates: LayoutCoordinates? = null
+
+        val setUpFinishedLatch = CountDownLatch(1)
+
+        var enableDynamicPointerInput by mutableStateOf(false)
+
+        // Events for the lower modifier Box 1
+        var originalPointerInputScopeExecutionCount by mutableStateOf(0)
+        var preexistingModifierPress by mutableStateOf(0)
+        var preexistingModifierMove by mutableStateOf(0)
+        var preexistingModifierRelease by mutableStateOf(0)
+
+        // All other events that should never be triggered in this test
+        var eventsThatShouldNotTrigger by mutableStateOf(false)
+
+        var pointerEvent: PointerEvent? by mutableStateOf(null)
+
+        // Events for the dynamic upper modifier Box 1
+        var dynamicModifierExecuted by mutableStateOf(false)
+
+        // Non-Pointer Input Modifier that is toggled on/off based on passed value.
+        fun Modifier.dynamicallyToggledModifier(enable: Boolean) = if (enable) {
+            dynamicModifierExecuted = true
+            background(Color.Green)
+        } else this
+
+        // Setup UI
+        rule.runOnUiThread {
+            container.setContent {
+                Box(
+                    Modifier
+                        .size(200.dp)
+                        .onGloballyPositioned {
+                            box1LayoutCoordinates = it
+                            setUpFinishedLatch.countDown()
+                        }
+                        .dynamicallyToggledModifier(enableDynamicPointerInput)
+                        .pointerInput(originalPointerInputModifierKey) {
+                            ++originalPointerInputScopeExecutionCount
+                            // Reset pointer events when lambda is ran the first time
+                            preexistingModifierPress = 0
+                            preexistingModifierMove = 0
+                            preexistingModifierRelease = 0
+
+                            awaitPointerEventScope {
+                                while (true) {
+                                    pointerEvent = awaitPointerEvent()
+                                    when (pointerEvent!!.type) {
+                                        PointerEventType.Press -> {
+                                            ++preexistingModifierPress
+                                        }
+                                        PointerEventType.Move -> {
+                                            ++preexistingModifierMove
+                                        }
+                                        PointerEventType.Release -> {
+                                            ++preexistingModifierRelease
+                                        }
+                                        else -> {
+                                            eventsThatShouldNotTrigger = true
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                ) { }
+            }
+        }
+        // Ensure Arrange (setup) step is finished
+        assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
+
+        // --> Act + Assert (interwoven)
+        // DOWN (original modifier only)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicModifierExecuted).isEqualTo(false)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(0)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original modifier only)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicModifierExecuted).isEqualTo(false)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original modifier only)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicModifierExecuted).isEqualTo(false)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+        enableDynamicPointerInput = true
+        rule.waitForFutureFrame(2)
+
+        // DOWN (original + dynamically added modifiers)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+
+        rule.runOnUiThread {
+            // There are no pointer input modifiers added above this pointer modifier, so the
+            // same one is used.
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            // The dynamic one has been added, so we execute its thing as well.
+            assertThat(dynamicModifierExecuted).isEqualTo(true)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicModifierExecuted).isEqualTo(true)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(2)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicModifierExecuted).isEqualTo(true)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(2)
+            assertThat(preexistingModifierRelease).isEqualTo(2)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+    }
+
+    /*
+     * Tests TOUCH events are triggered correctly when dynamically adding a pointer input modifier
+     * ABOVE an existing pointer input modifier.
+     *
+     * Note: The lambda for the existing pointer input modifier **IS** re-executed after the
+     * dynamic pointer input modifier is added above it.
+     *
+     * Specific events:
+     *  1. UI Element (modifier 1 only): PRESS (touch)
+     *  2. UI Element (modifier 1 only): MOVE (touch)
+     *  3. UI Element (modifier 1 only): RELEASE (touch)
+     *  4. Dynamically add pointer input modifier above existing one (between input event streams)
+     *  5. UI Element (modifier 1 and 2): PRESS (touch)
+     *  6. UI Element (modifier 1 and 2): MOVE (touch)
+     *  7. UI Element (modifier 1 and 2): RELEASE (touch)
+     */
+    @Test
+    fun dynamicInputModifierWithKey_addsAboveExistingModifier_shouldTriggerInNewModifier() {
+        // --> Arrange
+        val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
+        var box1LayoutCoordinates: LayoutCoordinates? = null
+
+        val setUpFinishedLatch = CountDownLatch(1)
+
+        var enableDynamicPointerInput by mutableStateOf(false)
+
+        // Events for the lower modifier Box 1
+        var originalPointerInputScopeExecutionCount by mutableStateOf(0)
+        var preexistingModifierPress by mutableStateOf(0)
+        var preexistingModifierMove by mutableStateOf(0)
+        var preexistingModifierRelease by mutableStateOf(0)
+
+        // Events for the dynamic upper modifier Box 1
+        var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
+        var dynamicModifierPress by mutableStateOf(0)
+        var dynamicModifierMove by mutableStateOf(0)
+        var dynamicModifierRelease by mutableStateOf(0)
+
+        // All other events that should never be triggered in this test
+        var eventsThatShouldNotTrigger by mutableStateOf(false)
+
+        var pointerEvent: PointerEvent? by mutableStateOf(null)
+
+        // Pointer Input Modifier that is toggled on/off based on passed value.
+        fun Modifier.dynamicallyToggledPointerInput(
+            enable: Boolean,
+            pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
+        ) = if (enable) {
+            pointerInput(pointerEventLambda) {
+                ++dynamicPointerInputScopeExecutionCount
+
+                // Reset pointer events when lambda is ran the first time
+                dynamicModifierPress = 0
+                dynamicModifierMove = 0
+                dynamicModifierRelease = 0
+
+                awaitPointerEventScope {
+                    while (true) {
+                        pointerEventLambda(awaitPointerEvent())
+                    }
+                }
+            }
+        } else this
+
+        // Setup UI
+        rule.runOnUiThread {
+            container.setContent {
+                Box(
+                    Modifier
+                        .size(200.dp)
+                        .onGloballyPositioned {
+                            box1LayoutCoordinates = it
+                            setUpFinishedLatch.countDown()
+                        }
+                        .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
+                            when (it.type) {
+                                PointerEventType.Press -> {
+                                    ++dynamicModifierPress
+                                }
+                                PointerEventType.Move -> {
+                                    ++dynamicModifierMove
+                                }
+                                PointerEventType.Release -> {
+                                    ++dynamicModifierRelease
+                                }
+                                else -> {
+                                    eventsThatShouldNotTrigger = true
+                                }
+                            }
+                        }
+                        .pointerInput(originalPointerInputModifierKey) {
+                            ++originalPointerInputScopeExecutionCount
+                            // Reset pointer events when lambda is ran the first time
+                            preexistingModifierPress = 0
+                            preexistingModifierMove = 0
+                            preexistingModifierRelease = 0
+
+                            awaitPointerEventScope {
+                                while (true) {
+                                    pointerEvent = awaitPointerEvent()
+                                    when (pointerEvent!!.type) {
+                                        PointerEventType.Press -> {
+                                            ++preexistingModifierPress
+                                        }
+                                        PointerEventType.Move -> {
+                                            ++preexistingModifierMove
+                                        }
+                                        PointerEventType.Release -> {
+                                            ++preexistingModifierRelease
+                                        }
+                                        else -> {
+                                            eventsThatShouldNotTrigger = true
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                ) { }
+            }
+        }
+        // Ensure Arrange (setup) step is finished
+        assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
+
+        // --> Act + Assert (interwoven)
+        // DOWN (original modifier only)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(0)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original modifier only)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original modifier only)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        enableDynamicPointerInput = true
+        rule.waitForFutureFrame(2)
+
+        // Important Note: Even though we reset all the pointer input blocks, the initial lambda is
+        // lazily executed, meaning it won't reset the values until the first event comes in, so
+        // the previously set values are still the same until an event comes in.
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+        }
+
+        // DOWN (original + dynamically added modifiers)
+        // Now an event comes in, so the lambdas are both executed completely (dynamic one for the
+        // first time and the existing one for a second time [since it was moved]).
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+
+        rule.runOnUiThread {
+            // While the original pointer input block is being reused after a new one is added, it
+            // is reset (since things have changed with the Modifiers), so the entire block is
+            // executed again to allow devs to reset their gesture detectors for the new Modifier
+            // chain changes.
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            // The dynamic one has been added, so we execute its thing as well.
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(0)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(1)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(1)
+            assertThat(dynamicModifierRelease).isEqualTo(1)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+    }
+
+    /*
+     * Tests TOUCH events are triggered incorrectly when dynamically adding a pointer input modifier
+     * (which uses Unit for its key [bad]) ABOVE an existing pointer input modifier. This is more
+     * of an "education test" for developers to see how things can go wrong if you use "Unit" for
+     * your key in pointer input and pointer input modifiers are later added dynamically.
+     *
+     * Note: Even though we are dynamically adding a new pointer input modifier above the existing
+     * pointer input modifier, Compose actually reuses the existing pointer input modifier to
+     * contain the new pointer input modifier. It then adds a new pointer input modifier below that
+     * one and copies in the original (non-dynamic) pointer input modifier into that. However, in
+     * this case, because we are using the "Unit" for both keys, Compose thinks they are the same
+     * pointer input modifier, so it never replaces the existing lambda with the dynamic pointer
+     * input modifier node's lambda. This is why you should not use Unit for your key.
+     *
+     * Why can't the lambdas passed into pointer input be compared? We can't memoize them because
+     * they are outside of a Compose scope (defined in a Modifier extension function), so
+     * developers need to pass a unique key(s) as a way to let us know when to update the lambda.
+     * You can do that with a unique key for each pointer input modifier and/or take it a step
+     * further and use captured values in the lambda as keys (ones that change lambda
+     * behavior).
+     *
+     * Specific events:
+     *  1. UI Element (modifier 1 only): PRESS (touch)
+     *  2. UI Element (modifier 1 only): MOVE (touch)
+     *  3. UI Element (modifier 1 only): RELEASE (touch)
+     *  4. Dynamically add pointer input modifier above existing one (between input event streams)
+     *  5. UI Element (modifier 1 and 2): PRESS (touch)
+     *  6. UI Element (modifier 1 and 2): MOVE (touch)
+     *  7. UI Element (modifier 1 and 2): RELEASE (touch)
+     */
+    @Test
+    fun dynamicInputModifierWithUnitKey_addsAboveExistingModifier_failsToTriggerNewModifier() {
+        // --> Arrange
+        var box1LayoutCoordinates: LayoutCoordinates? = null
+
+        val setUpFinishedLatch = CountDownLatch(1)
+
+        var enableDynamicPointerInput by mutableStateOf(false)
+
+        // Events for the lower modifier Box 1
+        var originalPointerInputScopeExecutionCount by mutableStateOf(0)
+        var preexistingModifierPress by mutableStateOf(0)
+        var preexistingModifierMove by mutableStateOf(0)
+        var preexistingModifierRelease by mutableStateOf(0)
+
+        // Events for the dynamic upper modifier Box 1
+        var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
+        var dynamicModifierPress by mutableStateOf(0)
+        var dynamicModifierMove by mutableStateOf(0)
+        var dynamicModifierRelease by mutableStateOf(0)
+
+        // All other events that should never be triggered in this test
+        var eventsThatShouldNotTrigger by mutableStateOf(false)
+
+        var pointerEvent: PointerEvent? by mutableStateOf(null)
+
+        // Pointer Input Modifier that is toggled on/off based on passed value.
+        fun Modifier.dynamicallyToggledPointerInput(
+            enable: Boolean,
+            pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
+        ) = if (enable) {
+            pointerInput(Unit) {
+                ++dynamicPointerInputScopeExecutionCount
+
+                // Reset pointer events when lambda is ran the first time
+                dynamicModifierPress = 0
+                dynamicModifierMove = 0
+                dynamicModifierRelease = 0
+
+                awaitPointerEventScope {
+                    while (true) {
+                        pointerEventLambda(awaitPointerEvent())
+                    }
+                }
+            }
+        } else this
+
+        // Setup UI
+        rule.runOnUiThread {
+            container.setContent {
+                Box(
+                    Modifier
+                        .size(200.dp)
+                        .onGloballyPositioned {
+                            box1LayoutCoordinates = it
+                            setUpFinishedLatch.countDown()
+                        }
+                        .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
+                            when (it.type) {
+                                PointerEventType.Press -> {
+                                    ++dynamicModifierPress
+                                }
+                                PointerEventType.Move -> {
+                                    ++dynamicModifierMove
+                                }
+                                PointerEventType.Release -> {
+                                    ++dynamicModifierRelease
+                                }
+                                else -> {
+                                    eventsThatShouldNotTrigger = true
+                                }
+                            }
+                        }
+                        .pointerInput(Unit) {
+                            ++originalPointerInputScopeExecutionCount
+                            awaitPointerEventScope {
+                                while (true) {
+                                    pointerEvent = awaitPointerEvent()
+                                    when (pointerEvent!!.type) {
+                                        PointerEventType.Press -> {
+                                            ++preexistingModifierPress
+                                        }
+                                        PointerEventType.Move -> {
+                                            ++preexistingModifierMove
+                                        }
+                                        PointerEventType.Release -> {
+                                            ++preexistingModifierRelease
+                                        }
+                                        else -> {
+                                            eventsThatShouldNotTrigger = true
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                ) { }
+            }
+        }
+        // Ensure Arrange (setup) step is finished
+        assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
+
+        // --> Act + Assert (interwoven)
+        // DOWN (original modifier only)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(0)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original modifier only)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original modifier only)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        enableDynamicPointerInput = true
+        rule.waitForFutureFrame(2)
+
+        // Important Note: I'm not resetting the variable counters in this test.
+
+        // DOWN (original + dynamically added modifiers)
+        // Now an event comes in, so the lambdas are both executed completely for the first time.
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+
+        rule.runOnUiThread {
+            // While the original pointer input block is being reused after a new one is added, it
+            // is reset (since things have changed with the Modifiers), so the entire block is
+            // executed again to allow devs to reset their gesture detectors for the new Modifier
+            // chain changes.
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            // The dynamic one has been added, so we execute its thing as well.
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            // This is 2 because the dynamic modifier added before the existing one, is using Unit
+            // for the key, so the comparison shows that it doesn't need to update the lambda...
+            // Thus, it uses the old lambda (why it is very important you don't use Unit for your
+            // key.
+            assertThat(preexistingModifierPress).isEqualTo(3)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(3)
+            assertThat(preexistingModifierMove).isEqualTo(3)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(2)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(3)
+            assertThat(preexistingModifierMove).isEqualTo(3)
+            assertThat(preexistingModifierRelease).isEqualTo(3)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+    }
+
+    /*
+     * Tests TOUCH events are triggered correctly when dynamically adding a pointer input
+     * modifier BELOW an existing pointer input modifier.
+     *
+     * Note: The lambda for the existing pointer input modifier is NOT re-executed after the
+     * dynamic one is added below it (since it doesn't impact it).
+     *
+     * Specific events:
+     *  1. UI Element (modifier 1 only): PRESS (touch)
+     *  2. UI Element (modifier 1 only): MOVE (touch)
+     *  3. UI Element (modifier 1 only): RELEASE (touch)
+     *  4. Dynamically add pointer input modifier below existing one (between input event streams)
+     *  5. UI Element (modifier 1 and 2): PRESS (touch)
+     *  6. UI Element (modifier 1 and 2): MOVE (touch)
+     *  7. UI Element (modifier 1 and 2): RELEASE (touch)
+     */
+    @Test
+    fun dynamicInputModifierWithKey_addsBelowExistingModifier_shouldTriggerInNewModifier() {
+        // --> Arrange
+        val originalPointerInputModifierKey = "ORIGINAL_POINTER_INPUT_MODIFIER_KEY_123"
+        var box1LayoutCoordinates: LayoutCoordinates? = null
+
+        val setUpFinishedLatch = CountDownLatch(1)
+
+        var enableDynamicPointerInput by mutableStateOf(false)
+
+        // Events for the lower modifier Box 1
+        var originalPointerInputScopeExecutionCount by mutableStateOf(0)
+        var preexistingModifierPress by mutableStateOf(0)
+        var preexistingModifierMove by mutableStateOf(0)
+        var preexistingModifierRelease by mutableStateOf(0)
+
+        // Events for the dynamic upper modifier Box 1
+        var dynamicPointerInputScopeExecutionCount by mutableStateOf(0)
+        var dynamicModifierPress by mutableStateOf(0)
+        var dynamicModifierMove by mutableStateOf(0)
+        var dynamicModifierRelease by mutableStateOf(0)
+
+        // All other events that should never be triggered in this test
+        var eventsThatShouldNotTrigger by mutableStateOf(false)
+
+        var pointerEvent: PointerEvent? by mutableStateOf(null)
+
+        // Pointer Input Modifier that is toggled on/off based on passed value.
+        fun Modifier.dynamicallyToggledPointerInput(
+            enable: Boolean,
+            pointerEventLambda: (pointerEvent: PointerEvent) -> Unit
+        ) = if (enable) {
+            pointerInput(pointerEventLambda) {
+                ++dynamicPointerInputScopeExecutionCount
+
+                // Reset pointer events when lambda is ran the first time
+                dynamicModifierPress = 0
+                dynamicModifierMove = 0
+                dynamicModifierRelease = 0
+
+                awaitPointerEventScope {
+                    while (true) {
+                        pointerEventLambda(awaitPointerEvent())
+                    }
+                }
+            }
+        } else this
+
+        // Setup UI
+        rule.runOnUiThread {
+            container.setContent {
+                Box(
+                    Modifier
+                        .size(200.dp)
+                        .onGloballyPositioned {
+                            box1LayoutCoordinates = it
+                            setUpFinishedLatch.countDown()
+                        }
+                        .pointerInput(originalPointerInputModifierKey) {
+                            ++originalPointerInputScopeExecutionCount
+                            // Reset pointer events when lambda is ran the first time
+                            preexistingModifierPress = 0
+                            preexistingModifierMove = 0
+                            preexistingModifierRelease = 0
+
+                            awaitPointerEventScope {
+                                while (true) {
+                                    pointerEvent = awaitPointerEvent()
+                                    when (pointerEvent!!.type) {
+                                        PointerEventType.Press -> {
+                                            ++preexistingModifierPress
+                                        }
+                                        PointerEventType.Move -> {
+                                            ++preexistingModifierMove
+                                        }
+                                        PointerEventType.Release -> {
+                                            ++preexistingModifierRelease
+                                        }
+                                        else -> {
+                                            eventsThatShouldNotTrigger = true
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                        .dynamicallyToggledPointerInput(enableDynamicPointerInput) {
+                            when (it.type) {
+                                PointerEventType.Press -> {
+                                    ++dynamicModifierPress
+                                }
+                                PointerEventType.Move -> {
+                                    ++dynamicModifierMove
+                                }
+                                PointerEventType.Release -> {
+                                    ++dynamicModifierRelease
+                                }
+                                else -> {
+                                    eventsThatShouldNotTrigger = true
+                                }
+                            }
+                        }
+                ) { }
+            }
+        }
+        // Ensure Arrange (setup) step is finished
+        assertTrue(setUpFinishedLatch.await(2, TimeUnit.SECONDS))
+
+        // --> Act + Assert (interwoven)
+        // DOWN (original modifier only)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(0)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original modifier only)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(0)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original modifier only)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(0)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(1)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(0)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        enableDynamicPointerInput = true
+        rule.waitForFutureFrame(2)
+
+        // DOWN (original + dynamically added modifiers)
+        dispatchTouchEvent(ACTION_DOWN, box1LayoutCoordinates!!)
+
+        rule.runOnUiThread {
+            // Because the new pointer input modifier is added below the existing one, the existing
+            // one doesn't change.
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(1)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(0)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // MOVE (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_MOVE,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(2)
+            assertThat(preexistingModifierRelease).isEqualTo(1)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(1)
+            assertThat(dynamicModifierRelease).isEqualTo(0)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+
+        // UP (original + dynamically added modifiers)
+        dispatchTouchEvent(
+            ACTION_UP,
+            box1LayoutCoordinates!!,
+            Offset(0f, box1LayoutCoordinates!!.size.height / 2 - 1f)
+        )
+        rule.runOnUiThread {
+            assertThat(originalPointerInputScopeExecutionCount).isEqualTo(1)
+            assertThat(dynamicPointerInputScopeExecutionCount).isEqualTo(1)
+
+            // Verify Box 1 existing modifier events
+            assertThat(preexistingModifierPress).isEqualTo(2)
+            assertThat(preexistingModifierMove).isEqualTo(2)
+            assertThat(preexistingModifierRelease).isEqualTo(2)
+
+            // Verify Box 1 dynamically added modifier events
+            assertThat(dynamicModifierPress).isEqualTo(1)
+            assertThat(dynamicModifierMove).isEqualTo(1)
+            assertThat(dynamicModifierRelease).isEqualTo(1)
+
+            assertThat(pointerEvent).isNotNull()
+            assertThat(eventsThatShouldNotTrigger).isFalse()
+        }
+    }
+
+    /*
      * Tests a full mouse event cycle from a press and release.
      *
      * Important Note: The pointer id should stay the same throughout all these events (part of the
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index ff9ed9d..3dc311e 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -278,7 +278,6 @@
          <trusted-key id="835A685C8C6F49C54980E5CAF406F31BC1468EBA" group="org.jcodec"/>
          <trusted-key id="842AFB86375D805422835BFD82B5574242C20D6F" group="org.antlr"/>
          <trusted-key id="8461EFA0E74ABAE010DE66994EB27DB2A3B88B8B" group="^androidx\..*" regex="true"/>
-         <trusted-key id="A5F483CD733A4EBAEA378B2AE88979FB9B30ACF2" group="^androidx\..*" regex="true"/>
          <trusted-key id="84789D24DF77A32433CE1F079EB80E92EB2135B1">
             <trusting group="org.apache"/>
             <trusting group="org.apache.maven" name="maven-parent"/>
@@ -340,6 +339,7 @@
          <trusted-key id="A4FD709CC4B0515F2E6AF04E218FA0F6A941A037" group="com.github.kevinstern"/>
          <trusted-key id="A5B2DDE7843E7CA3E8CAABD02383163BC40844FD" group="org.reactivestreams"/>
          <trusted-key id="A5BD02B93E7A40482EB1D66A5F69AD087600B22C" group="org.ow2.asm"/>
+         <trusted-key id="A5F483CD733A4EBAEA378B2AE88979FB9B30ACF2" group="^androidx\..*" regex="true"/>
          <trusted-key id="A6D6C97108B8585F91B158748671A8DF71296252" group="^com[.]squareup($|([.].*))" regex="true"/>
          <trusted-key id="A7892505CF1A58076453E52D7999BEFBA1039E8B" group="net.bytebuddy"/>
          <trusted-key id="AA417737BD805456DB3CBDDE6601E5C08DCCBB96" group="info.picocli" name="picocli"/>
diff --git a/gradlew b/gradlew
index 28d0634..04379b2 100755
--- a/gradlew
+++ b/gradlew
@@ -243,11 +243,6 @@
   disableCi=false
 fi
 
-# workaround for https://github.com/gradle/gradle/issues/18386
-if [[ " ${@} " =~ " --profile " ]]; then
-  mkdir -p reports
-fi
-
 # Expand some arguments
 for compact in "--ci" "--strict" "--clean" "--no-ci"; do
   expanded=""
@@ -259,7 +254,8 @@
        -Pandroidx.enableAffectedModuleDetection\
        -Pandroidx.printTimestamps\
        --no-watch-fs\
-       -Pandroidx.highMemory"
+       -Pandroidx.highMemory\
+       --profile"
     fi
   fi
   if [ "$compact" == "--strict" ]; then
@@ -302,6 +298,11 @@
   fi
 done
 
+# workaround for https://github.com/gradle/gradle/issues/18386
+if [[ " ${@} " =~ " --profile " ]]; then
+  mkdir -p reports
+fi
+
 raiseMemory=false
 if [[ " ${@} " =~ " -Pandroidx.highMemory " ]]; then
     raiseMemory=true
diff --git a/libraryversions.toml b/libraryversions.toml
index 197272c..3cb2699 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -22,7 +22,7 @@
 CARDVIEW = "1.1.0-alpha01"
 CAR_APP = "1.7.0-alpha01"
 COLLECTION = "1.5.0-alpha01"
-COMPOSE = "1.7.0-alpha05"
+COMPOSE = "1.7.0-alpha06"
 COMPOSE_COMPILER = "1.5.11"  # Update when preparing for a release
 COMPOSE_MATERIAL3 = "1.3.0-alpha03"
 COMPOSE_MATERIAL3_ADAPTIVE = "1.0.0-alpha09"
@@ -166,7 +166,7 @@
 WEAR_TILES = "1.4.0-alpha01"
 WEAR_TOOLING_PREVIEW = "1.0.0-rc01"
 WEAR_WATCHFACE = "1.3.0-alpha02"
-WEBKIT = "1.11.0-beta01"
+WEBKIT = "1.12.0-alpha01"
 # Adding a comment to prevent merge conflicts for Window artifact
 WINDOW = "1.3.0-beta01"
 WINDOW_EXTENSIONS = "1.3.0-alpha01"
diff --git a/navigation/navigation-common/src/androidTest/java/androidx/navigation/NavGraphTest.kt b/navigation/navigation-common/src/androidTest/java/androidx/navigation/NavGraphTest.kt
index b33cd9b..db7b497 100644
--- a/navigation/navigation-common/src/androidTest/java/androidx/navigation/NavGraphTest.kt
+++ b/navigation/navigation-common/src/androidTest/java/androidx/navigation/NavGraphTest.kt
@@ -148,7 +148,7 @@
         }
         assertThat(graph.startDestinationId).isEqualTo(15)
 
-        graph.setStartDestination(TestClass::class)
+        graph.setStartDestination<TestClass>()
         assertThat(graph.startDestinationRoute).isEqualTo("route/{arg}")
         assertThat(graph.startDestinationId).isEqualTo(serializer<TestClass>().hashCode())
     }
@@ -162,7 +162,7 @@
 
         // start destination not added via KClass, cannot match
         assertFailsWith<IllegalStateException> {
-            graph.setStartDestination(TestClass::class)
+            graph.setStartDestination<TestClass>()
         }
     }
 
diff --git a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
index 7f6ddf4..bff9da7 100644
--- a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
+++ b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraph.kt
@@ -367,10 +367,9 @@
      * @param startDestRoute The route of the destination as a [KClass] to be shown when navigating
      * to this NavGraph.
      */
-    @OptIn(InternalSerializationApi::class)
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-    public fun setStartDestination(startDestRoute: KClass<*>) {
-        setStartDestination(startDestRoute.serializer()) { startDestination ->
+    public inline fun <reified T> setStartDestination() {
+        setStartDestination(serializer<T>()) { startDestination ->
             startDestination.route!!
         }
     }
@@ -394,8 +393,10 @@
         }
     }
 
+    // unfortunately needs to be public so reified setStartDestination can access this
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     @OptIn(ExperimentalSerializationApi::class)
-    private fun <T> setStartDestination(
+    public fun <T> setStartDestination(
         serializer: KSerializer<T>,
         parseRoute: (NavDestination) -> String,
     ) {
@@ -403,7 +404,7 @@
         val startDest = findNode(id)
         checkNotNull(startDest) {
             "Cannot find startDestination ${serializer.descriptor.serialName} from NavGraph. " +
-                "Ensure the starting NavDestination was added via KClass."
+                "Ensure the starting NavDestination was added with route from KClass."
         }
         // when dest id is based on serializer, we expect the dest route to have been generated
         // and set
diff --git a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraphBuilder.kt b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraphBuilder.kt
index 6e42aff..e4213e9 100644
--- a/navigation/navigation-common/src/main/java/androidx/navigation/NavGraphBuilder.kt
+++ b/navigation/navigation-common/src/main/java/androidx/navigation/NavGraphBuilder.kt
@@ -21,6 +21,7 @@
 import kotlin.reflect.KClass
 import kotlin.reflect.KType
 import kotlinx.serialization.InternalSerializationApi
+import kotlinx.serialization.serializer
 
 /**
  * Construct a new [NavGraph]
@@ -64,8 +65,8 @@
 /**
  * Construct a new [NavGraph]
  *
- * @param startDestination the starting destination's route as a [KClass] for this NavGraph. The
- * respective NavDestination must be added as a [KClass] in order to match.
+ * @param startDestination the starting destination's route from a [KClass] for this NavGraph. The
+ * respective NavDestination must be added with route from a [KClass] in order to match.
  * @param route the graph's unique route as a [KClass]
  * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
  * if [route] uses custom NavTypes.
@@ -84,8 +85,8 @@
 /**
  * Construct a new [NavGraph]
  *
- * @param startDestination the starting destination's route as an Object for this NavGraph. The
- * respective NavDestination must be added as a [KClass] in order to match.
+ * @param startDestination the starting destination's route from an Object for this NavGraph. The
+ * respective NavDestination must be added with route from a [KClass] in order to match.
  * @param route the graph's unique route as a [KClass]
  * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
  * if [route] uses custom NavTypes.
@@ -142,9 +143,9 @@
 /**
  * Construct a nested [NavGraph]
  *
- * @param startDestination the starting destination's route as a [KClass] for this NavGraph. The
- * respective NavDestination must be added as a [KClass] in order to match.
- * @param route the graph's unique route as a [KClass]
+ * @param startDestination the starting destination's route from a [KClass] for this NavGraph. The
+ * respective NavDestination must be added with route from a [KClass] in order to match.
+ * @param route the graph's unique route from a [KClass]
  * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
  * if [route] uses custom NavTypes.
  *
@@ -161,9 +162,9 @@
 /**
  * Construct a nested [NavGraph]
  *
- * @param startDestination the starting destination's route as an Object for this NavGraph. The
- * respective NavDestination must be added as a [KClass] in order to match.
- * @param route the graph's unique route as a [KClass]
+ * @param startDestination the starting destination's route from an Object for this NavGraph. The
+ * respective NavDestination must be added with route from a [KClass] in order to match.
+ * @param route the graph's unique route from a [KClass]
  * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
  * if [route] uses custom NavTypes.
  *
@@ -240,7 +241,7 @@
      *
      * @param provider navigator used to create the destination
      * @param startDestination the starting destination's route as a [KClass] for this NavGraph. The
-     * respective NavDestination must be added as a [KClass] in order to match.
+     * respective NavDestination must be added with route from a [KClass] in order to match.
      * @param route the graph's unique route as a [KClass]
      * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
      * if [route] uses custom NavTypes.
@@ -263,7 +264,7 @@
      *
      * @param provider navigator used to create the destination
      * @param startDestination the starting destination's route as an Object for this NavGraph. The
-     * respective NavDestination must be added as a [KClass] in order to match.
+     * respective NavDestination must be added with route from a [KClass] in order to match.
      * @param route the graph's unique route as a [KClass]
      * @param typeMap A mapping of KType to custom NavType<*> in the [route]. Only necessary
      * if [route] uses custom NavTypes.
@@ -318,7 +319,7 @@
         if (startDestinationRoute != null) {
             navGraph.setStartDestination(startDestinationRoute!!)
         } else if (startDestinationClass != null) {
-            navGraph.setStartDestination(startDestinationClass!!)
+            navGraph.setStartDestination(startDestinationClass!!.serializer()) { it.route!! }
         } else if (startDestinationObject != null) {
             navGraph.setStartDestination(startDestinationObject!!)
         } else {
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.xml
index 8c9a6a1..a85bbad 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/current.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.testsdk.current.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.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
index cb99939..8e07975 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/currentWithResources.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/currentWithResources.xml
@@ -14,7 +14,7 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.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>
diff --git a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.xml
index 2bf4d37..38e0309 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v1.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.testsdk.v1.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.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.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v2.xml
index ed4f3707..2caa00d 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v2.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.testsdk.v2.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.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/v4.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v4.xml
index ea3c856..d1e82e8 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v4.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.testsdk.v4.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.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/assets/RuntimeEnabledSdks/v5.xml b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v5.xml
index 8d21c64..b8a7dc1 100644
--- a/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v5.xml
+++ b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/assets/RuntimeEnabledSdks/v5.xml
@@ -14,6 +14,6 @@
   limitations under the License.
   -->
 <compat-config>
-    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.v5.CompatProvider</compat-entrypoint>
+    <compat-entrypoint>androidx.privacysandbox.sdkruntime.testsdk.CompatProvider</compat-entrypoint>
     <dex-path>test-sdks/v5/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/config/LocalSdkConfigsHolderTest.kt b/privacysandbox/sdkruntime/sdkruntime-client/src/androidTest/java/androidx/privacysandbox/sdkruntime/client/config/LocalSdkConfigsHolderTest.kt
index e054456..de29be9 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
@@ -51,7 +51,7 @@
             packageName = "androidx.privacysandbox.sdkruntime.testsdk.current",
             versionMajor = 42,
             dexPaths = listOf("test-sdks/current/classes.dex"),
-            entryPoint = "androidx.privacysandbox.sdkruntime.testsdk.current.CompatProvider",
+            entryPoint = "androidx.privacysandbox.sdkruntime.testsdk.CompatProvider",
         )
 
         assertThat(result).isEqualTo(expectedConfig)
diff --git a/privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/current/CompatProvider.kt b/privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
similarity index 96%
rename from privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/current/CompatProvider.kt
rename to privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
index c6960bd..fc1f0b5 100644
--- a/privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/current/CompatProvider.kt
+++ b/privacysandbox/sdkruntime/test-sdks/current/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.privacysandbox.sdkruntime.testsdk.current
+package androidx.privacysandbox.sdkruntime.testsdk
 
 import android.content.Context
 import android.os.Binder
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/CompatProvider.kt
similarity index 97%
rename from privacysandbox/sdkruntime/test-sdks/v1/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v1/CompatProvider.kt
rename to privacysandbox/sdkruntime/test-sdks/v1/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
index c2b5312..307363e 100644
--- 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/CompatProvider.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.privacysandbox.sdkruntime.testsdk.v1
+package androidx.privacysandbox.sdkruntime.testsdk
 
 import android.content.Context
 import android.os.Binder
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/CompatProvider.kt
similarity index 95%
rename from privacysandbox/sdkruntime/test-sdks/v2/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v2/CompatProvider.kt
rename to privacysandbox/sdkruntime/test-sdks/v2/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
index cf4d8c7..8240d3ca 100644
--- 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/CompatProvider.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.privacysandbox.sdkruntime.testsdk.v2
+package androidx.privacysandbox.sdkruntime.testsdk
 
 import android.content.Context
 import android.os.Binder
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/CompatProvider.kt
similarity index 96%
rename from privacysandbox/sdkruntime/test-sdks/v4/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v4/CompatProvider.kt
rename to privacysandbox/sdkruntime/test-sdks/v4/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
index a97aec2..e24fb65 100644
--- 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/CompatProvider.kt
@@ -1,5 +1,5 @@
 /*
- * Copyright 2023 The Android Open Source Project
+ * Copyright 2024 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.privacysandbox.sdkruntime.testsdk.v4
+package androidx.privacysandbox.sdkruntime.testsdk
 
 import android.content.Context
 import android.os.Binder
diff --git a/privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v5/CompatProvider.kt b/privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
similarity index 98%
rename from privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v5/CompatProvider.kt
rename to privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
index bdc8581..857bdc9 100644
--- a/privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/v5/CompatProvider.kt
+++ b/privacysandbox/sdkruntime/test-sdks/v5/src/main/java/androidx/privacysandbox/sdkruntime/testsdk/CompatProvider.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package androidx.privacysandbox.sdkruntime.testsdk.v5
+package androidx.privacysandbox.sdkruntime.testsdk
 
 import android.content.Context
 import android.os.Binder
diff --git a/wear/compose/compose-foundation/build.gradle b/wear/compose/compose-foundation/build.gradle
index 4982912..9ac320a 100644
--- a/wear/compose/compose-foundation/build.gradle
+++ b/wear/compose/compose-foundation/build.gradle
@@ -49,6 +49,8 @@
     testImplementation(libs.junit)
     testImplementation(libs.truth)
     testImplementation(libs.kotlinTest)
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.robolectric)
 
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
@@ -56,6 +58,10 @@
     androidTestImplementation(libs.testRunner)
     androidTestImplementation(libs.kotlinTest)
     androidTestImplementation(libs.truth)
+
+    // Includes the wear-sdk jar
+    compileOnly files("../../wear_sdk/wear-sdk.jar")
+    testImplementation(files("../../wear_sdk/wear-sdk.jar"))
 }
 
 android {
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
index 373784a..dd78f66 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/rotary/Haptics.kt
@@ -16,6 +16,28 @@
 
 package androidx.wear.compose.foundation.rotary
 
+import android.content.Context
+import android.os.Build
+import android.provider.Settings
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import androidx.compose.foundation.gestures.ScrollableState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalView
+import com.google.wear.input.WearHapticFeedbackConstants
+import kotlin.math.abs
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.withContext
+
 /**
  * Handles haptics for rotary usage
  */
@@ -36,3 +58,313 @@
      */
     fun handleLimitHaptic(event: UnifiedRotaryEvent, isStart: Boolean)
 }
+
+@Composable
+internal fun rememberRotaryHapticHandler(
+    scrollableState: ScrollableState,
+    hapticsEnabled: Boolean
+): RotaryHapticHandler =
+    if (hapticsEnabled) {
+        // TODO(b/319103162): Add platform haptics once AndroidX updates to Android VanillaIceCream
+        rememberCustomRotaryHapticHandler(scrollableState)
+    } else {
+        rememberDisabledRotaryHapticHandler()
+    }
+
+/**
+ * Remembers custom rotary haptic handler.
+ * @param scrollableState A scrollableState, used to determine whether the end of the scrollable
+ * was reached or not.
+ */
+@Composable
+private fun rememberCustomRotaryHapticHandler(
+    scrollableState: ScrollableState,
+): RotaryHapticHandler {
+    val hapticsProvider = rememberRotaryHapticFeedbackProvider()
+    // Channel to which haptic events will be sent
+    val hapticsChannel: Channel<RotaryHapticsType> = rememberHapticChannel()
+
+    // Throttling events within specified timeframe.
+    // Only first and last events will be received. Check [throttleLatest] function for more info.
+    val throttleThresholdMs: Long = 30
+    // A scroll threshold after which haptic is produced.
+    val hapticsThresholdPx: Long = 50
+
+    LaunchedEffect(hapticsChannel, throttleThresholdMs) {
+        hapticsChannel.receiveAsFlow()
+            .throttleLatest(throttleThresholdMs)
+            .collect { hapticType ->
+                // 'withContext' launches performHapticFeedback in a separate thread,
+                // as otherwise it produces a visible lag (b/219776664)
+                val currentTime = System.currentTimeMillis()
+                debugLog { "Haptics started" }
+                withContext(Dispatchers.Default) {
+                    debugLog {
+                        "Performing haptics, delay: " +
+                            "${System.currentTimeMillis() - currentTime}"
+                    }
+                    hapticsProvider.performHapticFeedback(hapticType)
+                }
+            }
+    }
+    return remember(scrollableState, hapticsChannel, hapticsProvider) {
+        CustomRotaryHapticHandler(scrollableState, hapticsChannel, hapticsThresholdPx)
+    }
+}
+
+@Composable
+private fun rememberRotaryHapticFeedbackProvider(): RotaryHapticFeedbackProvider =
+    LocalView.current.let { view ->
+        remember {
+            val hapticConstants = getCustomRotaryConstants(view)
+            RotaryHapticFeedbackProvider(view, hapticConstants)
+        }
+    }
+
+@VisibleForTesting
+internal fun getCustomRotaryConstants(view: View): HapticConstants =
+    when {
+        // Order here is very important: We want to use WearSDK haptic constants for
+        // all devices having api 34 and up, but for Wear3.5 and Wear 4 constants should be
+        // different for Galaxy watches and other devices.
+        hasWearSDK() -> HapticConstants.WearSDKHapticConstants
+        isGalaxyWatch() -> HapticConstants.GalaxyWatchConstants
+        isWear3_5(view.context) -> HapticConstants.Wear3Point5RotaryHapticConstants
+        isWear4() -> HapticConstants.Wear4RotaryHapticConstants
+        else -> HapticConstants.DisabledHapticConstants
+    }
+
+@VisibleForTesting
+internal sealed class HapticConstants(
+    val scrollFocus: Int?,
+    val scrollTick: Int?,
+    val scrollLimit: Int?
+) {
+    /**
+     * Rotary haptic constants from WearSDK
+     */
+    object WearSDKHapticConstants : HapticConstants(
+        WearHapticFeedbackConstants.getScrollItemFocus(),
+        WearHapticFeedbackConstants.getScrollTick(),
+        WearHapticFeedbackConstants.getScrollLimit()
+    )
+
+    /**
+     * Rotary haptic constants for Galaxy Watch. These constants
+     * are used by Samsung for producing rotary haptics
+     */
+    object GalaxyWatchConstants : HapticConstants(
+        102, 101, 50107
+    )
+
+    /**
+     * Hidden constants from HapticFeedbackConstants.java
+     * API 33, Wear 4
+     */
+    object Wear4RotaryHapticConstants : HapticConstants(
+        19, 18, 20
+    )
+
+    /**
+     * Hidden constants from HapticFeedbackConstants.java
+     * API 30, Wear 3.5
+     */
+    object Wear3Point5RotaryHapticConstants : HapticConstants(
+        10003, 10002, 10003
+    )
+
+    object DisabledHapticConstants : HapticConstants(
+        null, null, null
+    )
+}
+
+@Composable
+private fun rememberHapticChannel() =
+    remember {
+        Channel<RotaryHapticsType>(
+            capacity = 2,
+            onBufferOverflow = BufferOverflow.DROP_OLDEST
+        )
+    }
+
+/**
+ * This class handles haptic feedback based
+ * on the [scrollableState], scrolled pixels and [hapticsThresholdPx].
+ * Haptic is not fired in this class, instead it's sent to [hapticsChannel]
+ * where it'll be performed later.
+ *
+ * @param scrollableState Haptic performed based on this state
+ * @param hapticsChannel Channel to which haptic events will be sent
+ * @param hapticsThresholdPx A scroll threshold after which haptic is produced.
+ */
+private class CustomRotaryHapticHandler(
+    private val scrollableState: ScrollableState,
+    private val hapticsChannel: Channel<RotaryHapticsType>,
+    private val hapticsThresholdPx: Long = 50
+) : RotaryHapticHandler {
+
+    private var overscrollHapticTriggered = false
+    private var currScrollPosition = 0f
+    private var prevHapticsPosition = 0f
+
+    override fun handleScrollHaptic(event: UnifiedRotaryEvent) {
+        if (scrollableState.reachedTheLimit(event.deltaInPixels)) {
+            handleLimitHaptic(event, scrollableState.canScrollBackward)
+        } else {
+            overscrollHapticTriggered = false
+            currScrollPosition += event.deltaInPixels
+            val diff = abs(currScrollPosition - prevHapticsPosition)
+
+            if (diff >= hapticsThresholdPx) {
+                hapticsChannel.trySend(RotaryHapticsType.ScrollTick)
+                prevHapticsPosition = currScrollPosition
+            }
+        }
+    }
+
+    override fun handleSnapHaptic(event: UnifiedRotaryEvent) {
+        if (scrollableState.reachedTheLimit(event.deltaInPixels)) {
+            handleLimitHaptic(event, scrollableState.canScrollBackward)
+        } else {
+            overscrollHapticTriggered = false
+            hapticsChannel.trySend(RotaryHapticsType.ScrollItemFocus)
+        }
+    }
+
+    override fun handleLimitHaptic(event: UnifiedRotaryEvent, isStart: Boolean) {
+        if (!overscrollHapticTriggered) {
+            hapticsChannel.trySend(RotaryHapticsType.ScrollLimit)
+            overscrollHapticTriggered = true
+        }
+    }
+}
+
+/**
+ * Rotary haptic types
+ */
+@JvmInline
+@VisibleForTesting
+internal value class RotaryHapticsType(private val type: Int) {
+    companion object {
+
+        /**
+         * A scroll ticking haptic. Similar to texture haptic - performed each time when
+         * a scrollable content is scrolled by a certain distance
+         */
+        public val ScrollTick: RotaryHapticsType = RotaryHapticsType(1)
+
+        /**
+         * An item focus (snap) haptic. Performed when a scrollable content is snapped
+         * to a specific item.
+         */
+        public val ScrollItemFocus: RotaryHapticsType = RotaryHapticsType(2)
+
+        /**
+         * A limit(overscroll) haptic. Performed when a list reaches the limit
+         * (start or end) and can't scroll further
+         */
+        public val ScrollLimit: RotaryHapticsType = RotaryHapticsType(3)
+    }
+}
+
+/**
+ * Remember disabled haptics handler
+ */
+@Composable
+private fun rememberDisabledRotaryHapticHandler(): RotaryHapticHandler = remember {
+    object : RotaryHapticHandler {
+        override fun handleScrollHaptic(event: UnifiedRotaryEvent) {
+            // Do nothing
+        }
+
+        override fun handleSnapHaptic(event: UnifiedRotaryEvent) {
+            // Do nothing
+        }
+
+        override fun handleLimitHaptic(event: UnifiedRotaryEvent, isStart: Boolean) {
+            // Do nothing
+        }
+    }
+}
+
+/**
+ * Rotary haptic feedback
+ */
+private class RotaryHapticFeedbackProvider(
+    private val view: View,
+    private val hapticConstants: HapticConstants
+) {
+    fun performHapticFeedback(
+        type: RotaryHapticsType,
+    ) {
+        when (type) {
+            RotaryHapticsType.ScrollItemFocus -> {
+                hapticConstants.scrollFocus?.let { view.performHapticFeedback(it) }
+            }
+
+            RotaryHapticsType.ScrollTick -> {
+                hapticConstants.scrollTick?.let { view.performHapticFeedback(it) }
+            }
+
+            RotaryHapticsType.ScrollLimit -> {
+                hapticConstants.scrollLimit?.let { view.performHapticFeedback(it) }
+            }
+        }
+    }
+}
+
+private fun isGalaxyWatch(): Boolean =
+    Build.MANUFACTURER.contains("Samsung", ignoreCase = true) &&
+        Build.MODEL.matches("^SM-R.*\$".toRegex())
+
+private fun isWear3_5(context: Context): Boolean =
+    Build.VERSION.SDK_INT == Build.VERSION_CODES.R && getWearPlatformMrNumber(context) >= 5
+
+private fun isWear4(): Boolean =
+    Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU
+
+private fun hasWearSDK(): Boolean =
+    Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+
+private fun getWearPlatformMrNumber(context: Context): Int =
+    Settings.Global
+        .getString(context.contentResolver, WEAR_PLATFORM_MR_NUMBER)?.toIntOrNull() ?: 0
+
+private const val WEAR_PLATFORM_MR_NUMBER: String = "wear_platform_mr_number"
+
+private fun ScrollableState.reachedTheLimit(scrollDelta: Float): Boolean =
+    (scrollDelta > 0 && !canScrollForward) || (scrollDelta < 0 && !canScrollBackward)
+
+/**
+ * Debug logging that can be enabled.
+ */
+private const val DEBUG = false
+
+private inline fun debugLog(generateMsg: () -> String) {
+    if (DEBUG) {
+        println("RotaryHaptics: ${generateMsg()}")
+    }
+}
+
+/**
+ * Throttling events within specified timeframe. Only first and last events will be received.
+ *
+ * For example, a flow emits elements 1 to 30, with a 100ms delay between them:
+ * ```
+ * val flow = flow {
+ *     for (i in 1..30) {
+ *         delay(100)
+ *         emit(i)
+ *     }
+ * }
+ * ```
+ * With timeframe=1000 only those integers will be received: 1, 10, 20, 30 .
+ */
+@VisibleForTesting
+internal fun <T> Flow<T>.throttleLatest(timeframe: Long): Flow<T> =
+    flow {
+        conflate().collect {
+            emit(it)
+            delay(timeframe)
+        }
+    }
diff --git a/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
new file mode 100644
index 0000000..da6eb40
--- /dev/null
+++ b/wear/compose/compose-foundation/src/test/kotlin/androidx/wear/compose/foundation/rotary/HapticsTest.kt
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.foundation.rotary
+
+import android.R
+import android.app.Activity
+import android.provider.Settings
+import android.view.View
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowBuild
+
+@RunWith(JUnit4::class)
+class ThrottleLatestTest {
+    private lateinit var testChannel: Channel<RotaryHapticsType>
+
+    @Before
+    fun before() {
+        testChannel = Channel(
+            capacity = 10,
+            onBufferOverflow = BufferOverflow.DROP_OLDEST
+        )
+    }
+
+    @Test
+    fun single_event_sent() = runTest {
+        val testFlow = testChannel.receiveAsFlow().throttleLatest(40)
+        val expectedItemsSize = 1
+
+        launch {
+            testChannel.trySend(RotaryHapticsType.ScrollTick)
+            testChannel.close()
+        }
+        val actualItems = testFlow.toList()
+
+        assertEquals(expectedItemsSize, actualItems.size)
+    }
+
+    @Test
+    fun three_events_sent_one_filtered() = runTest {
+        val testFlow = testChannel.receiveAsFlow().throttleLatest(40)
+        val expectedItemsSize = 2
+
+        // Send 3 events, receive 2 because they fall into a single timeframe and only
+        // 1st and last items are returned
+        launch {
+            testChannel.sendEventsWithDelay(RotaryHapticsType.ScrollTick, 3, 10)
+            testChannel.close()
+        }
+        val actualItems = testFlow.toList()
+
+        assertEquals(expectedItemsSize, actualItems.size)
+    }
+
+    @Test
+    fun three_events_sent_none_filtered() = runTest {
+        val testFlow = testChannel.receiveAsFlow().throttleLatest(40)
+        val expectedItemsSize = 3
+        // Sent 3 events, received 3 because delay between events is bigger than a timeframe
+        launch {
+            testChannel.sendEventsWithDelay(RotaryHapticsType.ScrollTick, 3, 50)
+            testChannel.close()
+        }
+        val actualItems = testFlow.toList()
+
+        assertEquals(expectedItemsSize, actualItems.size)
+    }
+
+    @Test
+    fun three_slow_and_five_fast() = runTest {
+        val testFlow = testChannel.receiveAsFlow().throttleLatest(40)
+        val expectedItemsSize = 5
+        launch {
+            // Sent 3 events, received 3 because delay between events is bigger than a timeframe
+            testChannel.sendEventsWithDelay(RotaryHapticsType.ScrollTick, 3, 50)
+            delay(50)
+            // Sent 5 events, received 2 (first and last) because delay between events
+            // was smaller than a timeframe
+            testChannel.sendEventsWithDelay(RotaryHapticsType.ScrollTick, 5, 5)
+            delay(5)
+            testChannel.close()
+        }
+
+        val actualItems = testFlow.toList()
+
+        assertEquals(expectedItemsSize, actualItems.size)
+    }
+
+    private suspend fun Channel<RotaryHapticsType>.sendEventsWithDelay(
+        event: RotaryHapticsType,
+        eventCount: Int,
+        delayMillis: Long
+    ) {
+        for (i in 0 until eventCount) {
+            trySend(event)
+            if (i < eventCount - 1) {
+                delay(delayMillis)
+            }
+        }
+    }
+}
+
+@RunWith(RobolectricTestRunner::class)
+class HapticsTest {
+    @Test
+    @Config(sdk = [33])
+    fun testPixelWatch1Wear4() {
+        ShadowBuild.setManufacturer("Google")
+        ShadowBuild.setModel("Google Pixel Watch")
+
+        assertEquals(HapticConstants.Wear4RotaryHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [30])
+    fun testPixelWatch1Wear35() {
+        ShadowBuild.setManufacturer("Google")
+        ShadowBuild.setModel("Google Pixel Watch")
+        Settings.Global.putString(
+            RuntimeEnvironment.getApplication().contentResolver,
+            "wear_platform_mr_number",
+            "5",
+        )
+
+        assertEquals(HapticConstants.Wear3Point5RotaryHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [33])
+    fun testGenericWear4() {
+        ShadowBuild.setManufacturer("XXX")
+        ShadowBuild.setModel("YYY")
+
+        assertEquals(HapticConstants.Wear4RotaryHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [30])
+    fun testGenericWear35() {
+        ShadowBuild.setManufacturer("XXX")
+        ShadowBuild.setModel("YYY")
+        Settings.Global.putString(
+            RuntimeEnvironment.getApplication().contentResolver,
+            "wear_platform_mr_number",
+            "5",
+        )
+
+        assertEquals(HapticConstants.Wear3Point5RotaryHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [30])
+    fun testGenericWear3() {
+        ShadowBuild.setManufacturer("XXX")
+        ShadowBuild.setModel("YYY")
+
+        assertEquals(HapticConstants.DisabledHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [28])
+    fun testGenericWear2() {
+        ShadowBuild.setManufacturer("XXX")
+        ShadowBuild.setModel("YYY")
+
+        assertEquals(HapticConstants.DisabledHapticConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [33])
+    fun testGalaxyWatchClassic() {
+        ShadowBuild.setManufacturer("Samsung")
+        // Galaxy Watch4 Classic
+        ShadowBuild.setModel("SM-R890")
+
+        assertEquals(HapticConstants.GalaxyWatchConstants, getHapticConstants())
+    }
+
+    @Test
+    @Config(sdk = [33])
+    fun testGalaxyWatch() {
+        ShadowBuild.setManufacturer("Samsung")
+        // Galaxy Watch 5 Pro
+        ShadowBuild.setModel("SM-R925")
+
+        assertEquals(HapticConstants.GalaxyWatchConstants, getHapticConstants())
+    }
+
+    private fun getHapticConstants(): HapticConstants {
+        val activity = Robolectric.buildActivity(Activity::class.java).get()
+        val view = activity.findViewById<View>(R.id.content)
+
+        return getCustomRotaryConstants(view)
+    }
+}