Merge "Rename PUBLISHED_KOTLIN_ONLY_LIBRARY for clarity and simplify optimising for kotlin only consumers" into androidx-main
diff --git a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt
new file mode 100644
index 0000000..bb2d93c
--- /dev/null
+++ b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextBenchmark.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.benchmark.text
+
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.testutils.benchmark.ComposeBenchmarkRule
+import androidx.compose.testutils.benchmark.benchmarkFirstCompose
+import androidx.compose.testutils.benchmark.benchmarkFirstDraw
+import androidx.compose.testutils.benchmark.benchmarkFirstLayout
+import androidx.compose.testutils.benchmark.benchmarkFirstMeasure
+import androidx.compose.testutils.benchmark.benchmarkLayoutPerf
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkDraw
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkLayout
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkMeasure
+import androidx.compose.testutils.benchmark.toggleStateBenchmarkRecompose
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.text.benchmark.TextBenchmarkTestRule
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.test.filters.SmallTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.RuleChain
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@SmallTest
+@RunWith(Parameterized::class)
+class BasicTextField2ToggleTextBenchmark(
+    private val textLength: Int
+) {
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "length={0}")
+        fun initParameters(): Array<Any> = arrayOf(32, 512).filterForCi()
+    }
+
+    private val textBenchmarkRule = TextBenchmarkTestRule()
+    private val benchmarkRule = ComposeBenchmarkRule()
+
+    @get:Rule
+    val testRule = RuleChain
+        .outerRule(textBenchmarkRule)
+        .around(benchmarkRule)
+
+    private val width = textBenchmarkRule.widthDp.dp
+    private val fontSize = textBenchmarkRule.fontSizeSp.sp
+
+    private val caseFactory = {
+        textBenchmarkRule.generator { generator ->
+            BasicTextField2ToggleTextTestCase(
+                textGenerator = generator,
+                textLength = textLength,
+                textNumber = textBenchmarkRule.repeatTimes,
+                width = width,
+                fontSize = fontSize
+            )
+        }
+    }
+
+    /**
+     * Measure the time taken to compose a [BasicTextField] composable from scratch with the
+     * given input. This is the time taken to call the [BasicTextField] composable function.
+     */
+    @Test
+    fun first_compose() {
+        benchmarkRule.benchmarkFirstCompose(caseFactory)
+    }
+
+    /**
+     * Measure the time taken by the first time measure the [BasicTextField] composable with the
+     * given input. This is mainly the time used to measure all the [Measurable]s in the
+     * [BasicTextField] composable.
+     */
+    @Test
+    fun first_measure() {
+        benchmarkRule.benchmarkFirstMeasure(caseFactory)
+    }
+
+    /**
+     * Measure the time taken by the first time layout the [BasicTextField] composable with the
+     * given input.
+     */
+    @Test
+    fun first_layout() {
+        benchmarkRule.benchmarkFirstLayout(caseFactory)
+    }
+
+    /**
+     * Measure the time taken by first time draw the [BasicTextField] composable with the given
+     * input.
+     */
+    @Test
+    fun first_draw() {
+        benchmarkRule.benchmarkFirstDraw(caseFactory)
+    }
+
+    /**
+     * Measure the time taken by layout the [BasicTextField] composable after the layout
+     * constrains changed. This is mainly the time used to re-measure and re-layout the composable.
+     */
+    @Test
+    fun layout() {
+        benchmarkRule.benchmarkLayoutPerf(caseFactory)
+    }
+
+    /**
+     * Measure the time taken to recompose the [BasicTextField] composable when text gets toggled.
+     */
+    @Test
+    fun toggleText_recompose() {
+        benchmarkRule.toggleStateBenchmarkRecompose(caseFactory, requireRecomposition = false)
+    }
+
+    /**
+     * Measure the time taken to measure the [BasicTextField] composable when text gets toggled.
+     */
+    @Test
+    fun toggleText_measure() {
+        benchmarkRule.toggleStateBenchmarkMeasure(
+            caseFactory = caseFactory,
+            toggleCausesRecompose = false,
+            assertOneRecomposition = false
+        )
+    }
+
+    /**
+     * Measure the time taken to layout the [BasicTextField] composable when text gets toggled.
+     */
+    @Test
+    fun toggleText_layout() {
+        benchmarkRule.toggleStateBenchmarkLayout(
+            caseFactory = caseFactory,
+            assertOneRecomposition = false,
+            toggleCausesRecompose = false
+        )
+    }
+
+    /**
+     * Measure the time taken to draw the [BasicTextField] composable when text gets toggled.
+     */
+    @Test
+    fun toggleText_draw() {
+        benchmarkRule.toggleStateBenchmarkDraw(
+            caseFactory = caseFactory,
+            toggleCausesRecompose = false,
+            assertOneRecomposition = false
+        )
+    }
+}
diff --git a/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt
new file mode 100644
index 0000000..c22686e
--- /dev/null
+++ b/compose/foundation/foundation/benchmark/src/androidTest/java/androidx/compose/foundation/benchmark/text/BasicTextField2ToggleTextTestCase.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:Suppress("DEPRECATION")
+
+package androidx.compose.foundation.benchmark.text
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.testutils.LayeredComposeTestCase
+import androidx.compose.testutils.ToggleableTestCase
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.InterceptPlatformTextInput
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.benchmark.RandomTextGenerator
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import kotlinx.coroutines.awaitCancellation
+
+@OptIn(ExperimentalComposeUiApi::class)
+class BasicTextField2ToggleTextTestCase(
+    private val textGenerator: RandomTextGenerator,
+    private val textLength: Int,
+    private val textNumber: Int,
+    private val width: Dp,
+    private val fontSize: TextUnit
+) : LayeredComposeTestCase(), ToggleableTestCase {
+
+    private val states = List(textNumber) {
+        TextFieldState(textGenerator.nextParagraph(length = textLength))
+    }
+
+    @Composable
+    override fun MeasuredContent() {
+        for (state in states) {
+            BasicTextField(
+                state = state,
+                textStyle = TextStyle(color = Color.Black, fontSize = fontSize),
+                modifier = Modifier
+                    .background(color = Color.Cyan)
+                    .requiredWidth(width)
+            )
+        }
+    }
+
+    @Composable
+    override fun ContentWrappers(content: @Composable () -> Unit) {
+        Column(
+            modifier = Modifier
+                .width(width)
+                .verticalScroll(rememberScrollState())
+        ) {
+            InterceptPlatformTextInput(
+                interceptor = { _, _ -> awaitCancellation() },
+                content = content
+            )
+        }
+    }
+
+    override fun toggleState() {
+        states.forEach {
+            it.setTextAndPlaceCursorAtEnd(textGenerator.nextParagraph(length = textLength))
+        }
+    }
+}
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 9b4690a..a476523 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
@@ -195,7 +195,9 @@
                 }
                 if (middleWedge) {
                     val index = asCharSequence().indexOf("ghi")
-                    insert(index, middle.text.toString())
+                    if (index > 0) {
+                        insert(index, middle.text.toString())
+                    }
                 }
                 if (suffixEnabled) {
                     append(suffix.text)
diff --git a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
index b90192b..237e51f 100644
--- a/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
+++ b/compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
@@ -596,6 +596,48 @@
     }
 
     @Test
+    fun moveItemToTheTop_itemWithMoreChildren_outsideBounds_shouldNotCrash() {
+        var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
+        val listSize = itemSize * 3
+        val listSizeDp = with(rule.density) { listSize.toDp() }
+        rule.setContent {
+            LazyList(
+                maxSize = listSizeDp,
+                startIndex = 3
+            ) {
+                items(list, key = { it }) {
+                    Item(it)
+                    if (it != list.last()) {
+                        Box(modifier = Modifier)
+                    }
+                }
+            }
+        }
+
+        assertPositions(
+            3 to 0f,
+            4 to itemSize,
+            5 to itemSize * 2
+        )
+
+        // move item 5 out of bounds
+        rule.runOnUiThread {
+            list = listOf(5, 0, 1, 2, 3, 4)
+        }
+
+        // should not crash
+        onAnimationFrame { fraction ->
+            if (fraction == 1.0f) {
+                assertPositions(
+                    2 to 0f,
+                    3 to itemSize,
+                    4 to itemSize * 2
+                )
+            }
+        }
+    }
+
+    @Test
     fun moveItemToTheBottomOutsideOfBounds_withSpacing() {
         var list by mutableStateOf(listOf(0, 1, 2, 3, 4, 5))
         val listSize = itemSize * 3 + spacing * 2
diff --git a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
index 3b003f7..d57ab48 100644
--- a/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
+++ b/compose/foundation/foundation/samples/src/main/java/androidx/compose/foundation/samples/LazyDslSamples.kt
@@ -96,7 +96,10 @@
             stickyHeader {
                 Text(
                     "Section $section",
-                    Modifier.fillMaxWidth().background(Color.LightGray).padding(8.dp)
+                    Modifier
+                        .fillMaxWidth()
+                        .background(Color.LightGray)
+                        .padding(8.dp)
                 )
             }
             items(10) {
@@ -109,9 +112,9 @@
 @Sampled
 @Composable
 fun AnimateItemSample() {
-    var list by remember { mutableStateOf(listOf("A", "B", "C")) }
+    var list by remember { mutableStateOf(listOf("1", "2", "3")) }
     Column {
-        Button(onClick = { list = list + "D" }) {
+        Button(onClick = { list = list + "${list.count() + 1}" }) {
             Text("Add new item")
         }
         Button(onClick = { list = list.shuffled() }) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
index c1da808..c49618d5 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/MagnifierTest.kt
@@ -409,6 +409,10 @@
     fun platformMagnifierModifier_updatesProperties_whenZoomChanged() {
         var zoom by mutableStateOf(1f)
         val platformMagnifier = CountingPlatformMagnifier()
+        val factory = PlatformMagnifierFactory(
+            platformMagnifier,
+            canUpdateZoom = true
+        )
         rule.setContent {
             Box(
                 Modifier.magnifier(
@@ -416,10 +420,7 @@
                     magnifierCenter = { Offset.Unspecified },
                     zoom = zoom,
                     onSizeChanged = null,
-                    platformMagnifierFactory = PlatformMagnifierFactory(
-                        platformMagnifier,
-                        canUpdateZoom = true
-                    )
+                    platformMagnifierFactory = factory
                 )
             )
         }
@@ -556,7 +557,7 @@
 
     @SdkSuppress(minSdkVersion = 28)
     @Test
-    fun platformMagnifierModifier_firesOnSizeChanged_initially_whenSourceCenterUnspecified() {
+    fun platformMagnifierModifier_doesNotFireOnSizeChanged_initially_whenSourceCenterUnspecified() {
         val magnifierSize = IntSize(10, 11)
         val sizeEvents = mutableListOf<DpSize>()
         val platformMagnifier = CountingPlatformMagnifier().apply {
@@ -574,6 +575,34 @@
             )
         }
 
+        rule.runOnIdle { assertThat(sizeEvents).isEmpty() }
+    }
+
+    @SdkSuppress(minSdkVersion = 28)
+    @Test
+    fun platformMagnifierModifier_firesOnSizeChanged_afterSourceCenterIsSpecified() {
+        val magnifierSize = IntSize(10, 11)
+        val sizeEvents = mutableListOf<DpSize>()
+        val platformMagnifier = CountingPlatformMagnifier().apply {
+            size = magnifierSize
+        }
+        var sourceCenter by mutableStateOf(Offset.Unspecified)
+        rule.setContent {
+            Box(
+                Modifier.magnifier(
+                    sourceCenter = { sourceCenter },
+                    magnifierCenter = { Offset.Unspecified },
+                    zoom = Float.NaN,
+                    onSizeChanged = { sizeEvents += it },
+                    platformMagnifierFactory = PlatformMagnifierFactory(platformMagnifier)
+                )
+            )
+        }
+
+        rule.runOnIdle { assertThat(sizeEvents).isEmpty() }
+
+        sourceCenter = Offset(1f, 1f)
+
         rule.runOnIdle {
             assertThat(sizeEvents).containsExactly(
                 with(rule.density) {
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
index 5b1a62f..37c4219 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/CoreTextFieldHandwritingGestureTest.kt
@@ -23,22 +23,31 @@
 import android.view.inputmethod.InputConnection
 import android.view.inputmethod.InsertGesture
 import android.view.inputmethod.JoinOrSplitGesture
+import android.view.inputmethod.PreviewableHandwritingGesture
 import android.view.inputmethod.RemoveSpaceGesture
 import android.view.inputmethod.SelectGesture
 import android.view.inputmethod.SelectRangeGesture
 import androidx.annotation.RequiresApi
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.setFocusableContent
 import androidx.compose.foundation.text.input.InputMethodInterceptor
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
+import androidx.compose.foundation.text.selection.TextSelectionColors
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixelColor
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
 import androidx.compose.ui.graphics.toAndroidRectF
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.toPixelMap
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.platform.LocalTextToolbar
@@ -47,12 +56,15 @@
 import androidx.compose.ui.platform.TextToolbarStatus
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.requestFocus
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.input.TextFieldValue
+import androidx.core.graphics.ColorUtils
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
@@ -72,6 +84,10 @@
 
     private val Tag = "CoreTextField"
 
+    private val backgroundColor = Color.Red
+    private val textColor = Color.Green
+    private val selectionColor = Color.Blue
+
     private val lineMargin = 16f
 
     @Test
@@ -96,6 +112,31 @@
     }
 
     @Test
+    fun textField_selectGesture_preview_wordLevel() {
+        val text = "abc def ghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val localBoundingBox = textLayoutResult.boundingBoxOf("b")
+                val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+                SelectGesture.Builder()
+                    .setSelectionArea(screenBoundingBox)
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("abc"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectGesture_characterLevel() {
         val text = "abcdef"
         testHandwritingGesture(
@@ -117,6 +158,31 @@
     }
 
     @Test
+    fun textField_selectGesture_preview_characterLevel() {
+        val text = "abc def ghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val localBoundingBox = textLayoutResult.boundingBoxOf("bc")
+                val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+                SelectGesture.Builder()
+                    .setSelectionArea(screenBoundingBox)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("bc"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectGesture_characterLevel_noSelection_insertFallbackText() {
         val text = "abcdef"
         val fallback = "fallbackText"
@@ -161,6 +227,29 @@
     }
 
     @Test
+    fun textField_selectGesture_preview_characterLevel_noSelection() {
+        val text = "abcdef"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { _ ->
+                SelectGesture.Builder()
+                    .setSelectionArea(RectF(0f, 0f, 1f, 1f))
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertNoHighlight(textLayoutResult)
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectGesture_wordLevel_noSelection_insertFallbackText() {
         val text = "abc def ghi"
         val fallback = "fallback"
@@ -211,6 +300,31 @@
     }
 
     @Test
+    fun textField_selectGesture_preview_wordLevel_noSelection() {
+        val text = "abc def ghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val localBoundingBox = textLayoutResult.boundingBoxOf("a")
+                val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+                SelectGesture.Builder()
+                    .setSelectionArea(screenBoundingBox)
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertNoHighlight(textLayoutResult)
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteGesture_wordLevel_removeSpaceBeforeDeletion() {
         val text = "abc def ghi"
         testHandwritingGesture(
@@ -234,6 +348,31 @@
     }
 
     @Test
+    fun textField_deleteGesture_preview_wordLevel() {
+        val text = "abc def ghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val localBoundingBox = textLayoutResult.boundingBoxOf("h")
+                val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+                DeleteGesture.Builder()
+                    .setDeletionArea(screenBoundingBox)
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("ghi"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteGesture_wordLevel_onlyRemoveSpaceBeforeDeletion() {
         val text = "abc\n def ghi"
         testHandwritingGesture(
@@ -327,6 +466,31 @@
     }
 
     @Test
+    fun textField_deleteGesture_preview_characterLevel() {
+        val text = "abcdefghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val localBoundingBox = textLayoutResult.boundingBoxOf("def")
+                val screenBoundingBox = localToScreen(localBoundingBox).toAndroidRectF()
+                DeleteGesture.Builder()
+                    .setDeletionArea(screenBoundingBox)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("def"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteGesture_characterLevel_notRemoveSpaces() {
         val text = "abcdef ghi"
         testHandwritingGesture(
@@ -397,6 +561,29 @@
         }
     }
 
+    @Test
+    fun textField_deleteGesture_preview_noDeletion() {
+        val text = "abc def ghi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { _ ->
+                DeleteGesture.Builder()
+                    .setDeletionArea(RectF(-1f, -1f, 0f, 0f))
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertNoHighlight(textLayoutResult)
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
     fun textField_selectRangeGesture_characterLevel() {
         val text = "abc\ndef"
         testHandwritingGesture(
@@ -425,6 +612,38 @@
     }
 
     @Test
+    fun textField_selectRangeGesture_preview_characterLevel() {
+        val text = "abc\ndef"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val startArea = textLayoutResult.boundingBoxOf("c").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                val endArea = textLayoutResult.boundingBoxOf("d").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                SelectRangeGesture.Builder()
+                    .setSelectionStartArea(startArea)
+                    .setSelectionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertSelectionPreviewHighlight(textLayoutResult, text.rangeOf("c\nd"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectRangeGesture_wordLevel() {
         val text = "abc\ndef jhi"
         testHandwritingGesture(
@@ -453,6 +672,41 @@
     }
 
     @Test
+    fun textField_selectRangeGesture_preview_wordLevel() {
+        val text = "abc\ndef jhi"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val startArea = textLayoutResult.boundingBoxOf("b").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                val endArea = textLayoutResult.boundingBoxOf("e").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                SelectRangeGesture.Builder()
+                    .setSelectionStartArea(startArea)
+                    .setSelectionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertSelectionPreviewHighlight(
+                    textLayoutResult,
+                    text.rangeOf("abc\ndef")
+                )
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectRangeGesture_nothingSelectedInStartArea_insertFallbackText() {
         val text = "abc\ndef"
         val fallback = "fallbackText"
@@ -515,6 +769,35 @@
     }
 
     @Test
+    fun textField_selectRangeGesture_preview_nothingSelectedInStartArea() {
+        val text = "abc\ndef"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val endArea = textLayoutResult.boundingBoxOf("d").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+                // The startArea selects nothing, but the endArea contains one character, it
+                // should still fallback.
+                SelectRangeGesture.Builder()
+                    .setSelectionStartArea(RectF(0f, 0f, 1f, 1f))
+                    .setSelectionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertNoHighlight(textLayoutResult)
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_selectRangeGesture_noSelection_fail() {
         val text = "abcdef"
         testHandwritingGesture(
@@ -564,6 +847,37 @@
     }
 
     @Test
+    fun textField_deleteRangeGesture_preview_characterLevel() {
+        val text = "abc\ndef"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val startArea = textLayoutResult.boundingBoxOf("c").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+                val endArea = textLayoutResult.boundingBoxOf("d").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                DeleteRangeGesture.Builder()
+                    .setDeletionStartArea(startArea)
+                    .setDeletionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertDeletionPreviewHighlight(textLayoutResult, text.rangeOf("c\nd"))
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteRangeGesture_wordLevel() {
         val text = "abc def\n jhi lmn"
         testHandwritingGesture(
@@ -592,6 +906,39 @@
     }
 
     @Test
+    fun textField_deleteRangeGesture_preview_wordLevel() {
+        val text = "abc def\n jhi lmn"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val startArea = textLayoutResult.boundingBoxOf("e").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+                val endArea = textLayoutResult.boundingBoxOf("h").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+
+                DeleteRangeGesture.Builder()
+                    .setDeletionStartArea(startArea)
+                    .setDeletionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_WORD)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertDeletionPreviewHighlight(
+                    textLayoutResult, text.rangeOf("def\n jhi")
+                )
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteRangeGesture_nothingDeletedInStartArea_insertFallbackText() {
         val text = "abc\ndef"
         val fallback = "fallbackText"
@@ -654,6 +1001,35 @@
     }
 
     @Test
+    fun textField_deleteRangeGesture_preview_nothingDeletedInStartArea() {
+        val text = "abc def\n jhi lmn"
+        val initialCursor = 3
+        testHandwritingGesture(
+            text = text,
+            initialSelection = TextRange(initialCursor),
+            gestureFactory = { textLayoutResult ->
+                val endArea = textLayoutResult.boundingBoxOf("d").let {
+                    localToScreen(it).toAndroidRectF()
+                }
+                // The startArea selects nothing, but the endArea contains one character, it
+                // should still fallback.
+                DeleteRangeGesture.Builder()
+                    .setDeletionStartArea(RectF(0f, 0f, 1f, 1f))
+                    .setDeletionEndArea(endArea)
+                    .setGranularity(HandwritingGesture.GRANULARITY_CHARACTER)
+                    .build()
+            },
+            preview = true,
+            imageAssertion = { imageBitmap, textLayoutResult ->
+                imageBitmap.assertNoHighlight(textLayoutResult)
+            }
+        ) { textFieldValue, _, _ ->
+            assertThat(textFieldValue.text).isEqualTo(text)
+            assertThat(textFieldValue.selection).isEqualTo(TextRange(initialCursor))
+        }
+    }
+
+    @Test
     fun textField_deleteRangeGesture_noDeletion_fail() {
         val text = "abcdef"
         testHandwritingGesture(
@@ -1268,6 +1644,8 @@
         text: String,
         initialSelection: TextRange = TextRange(text.length),
         gestureFactory: LayoutCoordinates.(TextLayoutResult) -> HandwritingGesture,
+        preview: Boolean = false,
+        imageAssertion: ((ImageBitmap, TextLayoutResult) -> Unit)? = null,
         assertion: (TextFieldValue, resultCode: Int, TextToolbar) -> Unit
     ) {
         var textFieldValue by mutableStateOf(TextFieldValue(text, initialSelection))
@@ -1280,14 +1658,20 @@
                 override val handwritingGestureLineMargin: Float = lineMargin
             }
             CompositionLocalProvider(
+                LocalTextSelectionColors provides TextSelectionColors(
+                    selectionColor,
+                    selectionColor
+                ),
                 LocalTextToolbar provides textToolbar,
                 LocalViewConfiguration provides viewConfiguration
             ) {
                 CoreTextField(
                     value = textFieldValue,
                     onValueChange = { textFieldValue = it },
+                    textStyle = TextStyle(color = textColor),
                     modifier = Modifier
                         .fillMaxSize()
+                        .background(backgroundColor)
                         .testTag(Tag)
                         .onGloballyPositioned { layoutCoordinates = it },
                     onTextLayout = {
@@ -1303,12 +1687,20 @@
         var resultCode = InputConnection.HANDWRITING_GESTURE_RESULT_UNKNOWN
 
         inputMethodInterceptor.withInputConnection {
-            performHandwritingGesture(gesture, /* executor= */null) { resultCode = it }
+            if (preview) {
+                previewHandwritingGesture(gesture as PreviewableHandwritingGesture, null)
+            } else {
+                performHandwritingGesture(gesture, /* executor= */ null) { resultCode = it }
+            }
         }
 
         rule.runOnIdle {
             assertion.invoke(textFieldValue, resultCode, textToolbar)
         }
+
+        if (imageAssertion != null) {
+            imageAssertion(rule.onNodeWithTag(Tag).captureToImage(), textLayoutResult!!)
+        }
     }
 
     private fun setContent(
@@ -1327,6 +1719,56 @@
         return rect.translate(localOriginInScreen)
     }
 
+    private fun ImageBitmap.assertSelectionPreviewHighlight(
+        textLayoutResult: TextLayoutResult,
+        range: TextRange
+    ) {
+        assertHighlight(textLayoutResult, range, selectionColor)
+    }
+
+    private fun ImageBitmap.assertDeletionPreviewHighlight(
+        textLayoutResult: TextLayoutResult,
+        range: TextRange
+    ) {
+        val deletionPreviewColor = textColor.copy(alpha = textColor.alpha * 0.2f)
+        val compositeColor = Color(
+            ColorUtils.compositeColors(
+                deletionPreviewColor.toArgb(),
+                backgroundColor.toArgb()
+            )
+        )
+        assertHighlight(textLayoutResult, range, compositeColor)
+    }
+
+    private fun ImageBitmap.assertNoHighlight(textLayoutResult: TextLayoutResult) {
+        assertHighlight(textLayoutResult, TextRange.Zero, Color.Unspecified)
+    }
+
+    private fun ImageBitmap.assertHighlight(
+        textLayoutResult: TextLayoutResult,
+        range: TextRange,
+        highlightColor: Color
+    ) {
+        val pixelMap =
+            toPixelMap(width = textLayoutResult.size.width, height = textLayoutResult.size.height)
+        for (offset in 0 until textLayoutResult.layoutInput.text.length) {
+            if (textLayoutResult.layoutInput.text[offset] == '\n') {
+                continue
+            }
+            // Check the top left pixel of each character (assumes LTR). This pixel is always part
+            // of the background (not part of the text foreground).
+            val line = textLayoutResult.multiParagraph.getLineForOffset(offset)
+            val lineTop = textLayoutResult.multiParagraph.getLineTop(line).ceilToIntPx()
+            val horizontal =
+                textLayoutResult.multiParagraph.getHorizontalPosition(offset, true).ceilToIntPx()
+            if (offset in range) {
+                pixelMap.assertPixelColor(highlightColor, horizontal, lineTop)
+            } else {
+                pixelMap.assertPixelColor(backgroundColor, horizontal, lineTop)
+            }
+        }
+    }
+
     private fun FakeTextToolbar(): TextToolbar {
         return object : TextToolbar {
             private var _status: TextToolbarStatus = TextToolbarStatus.Hidden
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
index 938bbf7..c5dd825 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/TextFieldDelegateIntegrationTest.kt
@@ -70,15 +70,52 @@
         TextFieldDelegate.draw(
             canvas = actualCanvas,
             value = TextFieldValue(text = "Hello, World", selection = selection),
-            selectionPaint = Paint().apply { color = selectionColor },
+            selectionPreviewHighlightRange = TextRange.Zero,
+            deletionPreviewHighlightRange = TextRange.Zero,
             offsetMapping = OffsetMapping.Identity,
-            textLayoutResult = layoutResult
+            textLayoutResult = layoutResult,
+            highlightPaint = Paint(),
+            selectionBackgroundColor = selectionColor
         )
 
         assertThat(actualBitmap.sameAs(expectedBitmap)).isTrue()
     }
 
     @Test
+    fun draw_highlight_test() {
+        val textDelegate = TextDelegate(
+            text = AnnotatedString("Hello, World"),
+            style = TextStyle.Default,
+            maxLines = 2,
+            density = density,
+            fontFamilyResolver = fontFamilyResolver
+        )
+        val layoutResult = textDelegate.layout(Constraints.fixedWidth(1024), layoutDirection)
+        val deletionPreviewHighlightRange = TextRange(3, 5)
+
+        val actualBitmap = layoutResult.toBitmap()
+        val actualCanvas = Canvas(android.graphics.Canvas(actualBitmap))
+        TextFieldDelegate.draw(
+            canvas = actualCanvas,
+            value = TextFieldValue(text = "Hello, World", selection = TextRange.Zero),
+            selectionPreviewHighlightRange = TextRange.Zero,
+            deletionPreviewHighlightRange = deletionPreviewHighlightRange,
+            offsetMapping = OffsetMapping.Identity,
+            textLayoutResult = layoutResult,
+            highlightPaint = Paint(),
+            selectionBackgroundColor = Color.Blue
+        )
+
+        val expectedBitmap = layoutResult.toBitmap()
+        val expectedCanvas = Canvas(android.graphics.Canvas(expectedBitmap))
+        val selectionPath = layoutResult.multiParagraph.getPathForRange(3, 5)
+        // Default text color is black, so deletion preview highlight is black with 20% alpha.
+        expectedCanvas.drawPath(selectionPath, Paint().apply { color = Color(0f, 0f, 0f, 0.2f) })
+        TextPainter.paint(expectedCanvas, layoutResult)
+        assertThat(actualBitmap.sameAs(expectedBitmap)).isTrue()
+    }
+
+    @Test
     fun layout_height_constraint_max_height() {
         val textDelegate = TextDelegate(
             text = AnnotatedString("Hello, World"),
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
index 683499e..2a4c26e4 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/input/internal/EditorInfoTest.kt
@@ -18,8 +18,13 @@
 
 import android.text.InputType
 import android.view.inputmethod.DeleteGesture
+import android.view.inputmethod.DeleteRangeGesture
 import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InsertGesture
+import android.view.inputmethod.JoinOrSplitGesture
+import android.view.inputmethod.RemoveSpaceGesture
 import android.view.inputmethod.SelectGesture
+import android.view.inputmethod.SelectRangeGesture
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.ImeOptions
@@ -599,8 +604,29 @@
         val info = EditorInfo()
         info.update(ImeOptions.Default)
 
-        assertThat(info.supportedHandwritingGestures).contains(SelectGesture::class.java)
-        assertThat(info.supportedHandwritingGestures).contains(DeleteGesture::class.java)
+        assertThat(info.supportedHandwritingGestures).containsExactly(
+            SelectGesture::class.java,
+            DeleteGesture::class.java,
+            SelectRangeGesture::class.java,
+            DeleteRangeGesture::class.java,
+            JoinOrSplitGesture::class.java,
+            InsertGesture::class.java,
+            RemoveSpaceGesture::class.java,
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = 34)
+    @Test
+    fun supportedStylusHandwritingGesturePreviews() {
+        val info = EditorInfo()
+        info.update(ImeOptions.Default)
+
+        assertThat(info.supportedHandwritingGesturePreviews).containsExactly(
+            SelectGesture::class.java,
+            DeleteGesture::class.java,
+            SelectRangeGesture::class.java,
+            DeleteRangeGesture::class.java,
+        )
     }
 
     private fun EditorInfo.update(
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
index 186bdd2..7d2e1e9 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/Magnifier.android.kt
@@ -20,8 +20,11 @@
 import android.view.View
 import android.widget.Magnifier
 import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.neverEqualPolicy
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.withFrameMillis
 import androidx.compose.ui.Modifier
@@ -39,7 +42,6 @@
 import androidx.compose.ui.node.requireDensity
 import androidx.compose.ui.node.requireView
 import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.SemanticsPropertyKey
 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
 import androidx.compose.ui.unit.Density
@@ -48,6 +50,7 @@
 import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.toSize
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.launch
 
 /**
@@ -263,7 +266,7 @@
     private var view: View? = null
 
     /**
-     * Current density provided by [LocalDensity]. Used as a receiver to callback functions that
+     * Current density provided by [requireDensity]. Used as a receiver to callback functions that
      * are expected return pixel targeted offsets.
      */
     private var density: Density? = null
@@ -274,9 +277,28 @@
     private var magnifier: PlatformMagnifier? = null
 
     /**
-     * Anchor Composable's position in root layout.
+     * The latest [LayoutCoordinates] that is reported by [onGloballyPositioned] callback. Using
+     * [neverEqualPolicy] guarantees that every update to this value restarts snapshots aware
+     * listeners since the [LayoutCoordinates] instance itself does not change.
      */
-    private var anchorPositionInRoot: Offset by mutableStateOf(Offset.Unspecified)
+    private var layoutCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
+
+    /**
+     * Lazily initialized state that keeps track of anchor Composable's position in root layout.
+     * This state should be derived from [layoutCoordinates]. This variable shouldn't be used
+     * directly from the code, only [anchorPositionInRoot] should initialize and read from this.
+     */
+    private var anchorPositionInRootState: State<Offset>? = null
+
+    private val anchorPositionInRoot: Offset
+        get() {
+            if (anchorPositionInRootState == null) {
+                anchorPositionInRootState = derivedStateOf {
+                    layoutCoordinates?.positionInRoot() ?: Offset.Unspecified
+                }
+            }
+            return anchorPositionInRootState?.value ?: Offset.Unspecified
+        }
 
     /**
      * Position where [sourceCenter] is mapped on root layout. This is passed to platform magnifier
@@ -290,6 +312,8 @@
      */
     private var previousSize: IntSize? = null
 
+    private var drawSignalChannel: Channel<Unit>? = null
+
     fun update(
         sourceCenter: Density.() -> Offset,
         magnifierCenter: (Density.() -> Offset)?,
@@ -305,9 +329,12 @@
         val previousZoom = this.zoom
         val previousSize = this.size
         val previousCornerRadius = this.cornerRadius
+        val previousUseTextDefault = this.useTextDefault
         val previousElevation = this.elevation
         val previousClippingEnabled = this.clippingEnabled
         val previousPlatformMagnifierFactory = this.platformMagnifierFactory
+        val previousView = this.view
+        val previousDensity = this.density
 
         this.sourceCenter = sourceCenter
         this.magnifierCenter = magnifierCenter
@@ -320,26 +347,45 @@
         this.onSizeChanged = onSizeChanged
         this.platformMagnifierFactory = platformMagnifierFactory
 
-        // On platforms >=Q, the zoom level can be updated dynamically on an existing magnifier, so
-        // if the zoom changes between recompositions we don't need to recreate the magnifier. On
-        // older platforms, the zoom can only be set initially, so we use the zoom itself as a key
-        // so the magnifier gets recreated if it changes.
-        if (
-            magnifier == null ||
-            (zoom != previousZoom && !platformMagnifierFactory.canUpdateZoom) ||
-            size != previousSize ||
-            cornerRadius != previousCornerRadius ||
-            elevation != previousElevation ||
-            clippingEnabled != previousClippingEnabled ||
-            platformMagnifierFactory != previousPlatformMagnifierFactory
-        ) {
+        val view = requireView()
+        val density = requireDensity()
+
+        val shouldRecreate = magnifier != null && // only recreate if it was already created
+            // On platforms >=Q, the zoom level can be updated dynamically on an existing magnifier,
+            // so if the zoom changes between recompositions we don't need to recreate the
+            // magnifier. On older platforms, the zoom can only be set initially, so we use the
+            // zoom itself as a key so the magnifier gets recreated if it changes.
+            ((!zoom.equalsIncludingNaN(previousZoom) && !platformMagnifierFactory.canUpdateZoom) ||
+                size != previousSize ||
+                cornerRadius != previousCornerRadius ||
+                elevation != previousElevation ||
+                useTextDefault != previousUseTextDefault ||
+                clippingEnabled != previousClippingEnabled ||
+                platformMagnifierFactory != previousPlatformMagnifierFactory ||
+                view != previousView ||
+                density != previousDensity)
+
+        if (shouldRecreate) {
             recreateMagnifier()
         }
+
         updateMagnifier()
     }
 
     override fun onAttach() {
         onObservedReadsChanged()
+        drawSignalChannel = Channel()
+        coroutineScope.launch {
+            while (true) {
+                drawSignalChannel?.receive()
+                // don't update the magnifier immediately, actual frame draw happens right after
+                // all draw commands are recorded. Magnifier update should happen in the next frame.
+                if (magnifier != null) {
+                    withFrameMillis { }
+                    magnifier?.updateContent()
+                }
+            }
+        }
     }
 
     override fun onDetach() {
@@ -349,23 +395,14 @@
 
     override fun onObservedReadsChanged() {
         observeReads {
-            val previousView = view
-            val view = requireView().also { this.view = it }
-            val previousDensity = density
-            val density = requireDensity().also { this.density = it }
-
-            if (magnifier == null || view != previousView || density != previousDensity) {
-                recreateMagnifier()
-            }
-
             updateMagnifier()
         }
     }
 
     private fun recreateMagnifier() {
         magnifier?.dismiss()
-        val view = view ?: return
-        val density = density ?: return
+        val view = (view ?: requireView()).also { view = it }
+        val density = (density ?: requireDensity()).also { density = it }
         magnifier = platformMagnifierFactory.create(
             view = view,
             useTextDefault = useTextDefault,
@@ -380,37 +417,38 @@
     }
 
     private fun updateMagnifier() {
-        val magnifier = magnifier ?: return
-        val density = density ?: return
+        val density = density ?: requireDensity().also { density = it }
 
         val sourceCenterOffset = sourceCenter(density)
-        sourceCenterInRoot =
-            if (anchorPositionInRoot.isSpecified && sourceCenterOffset.isSpecified) {
-                anchorPositionInRoot + sourceCenterOffset
-            } else {
-                Offset.Unspecified
-            }
 
-        // Once the position is set, it's never null again, so we don't need to worry
-        // about dismissing the magnifier if this expression changes value.
-        if (sourceCenterInRoot.isSpecified) {
-            // Calculate magnifier center if it's provided. Only accept if the returned value is
-            // specified. Then add [anchorPositionInRoot] for relative positioning.
+        // the order of these checks are important since we don't want to query
+        // `anchorPositionInRoot` if `sourceCenterOffset` is unspecified.
+        if (sourceCenterOffset.isSpecified && anchorPositionInRoot.isSpecified) {
+            sourceCenterInRoot = anchorPositionInRoot + sourceCenterOffset
+            // Calculate magnifier center if it's provided. Only accept if the returned
+            // value is specified. Then add [anchorPositionInRoot] for relative positioning.
             val magnifierCenter = magnifierCenter?.invoke(density)
                 ?.takeIf { it.isSpecified }
                 ?.let { anchorPositionInRoot + it }
                 ?: Offset.Unspecified
 
-            magnifier.update(
+            if (magnifier == null) {
+                recreateMagnifier()
+            }
+
+            magnifier?.update(
                 sourceCenter = sourceCenterInRoot,
                 magnifierCenter = magnifierCenter,
                 zoom = zoom
             )
             updateSizeIfNecessary()
-        } else {
-            // Can't place the magnifier at an unspecified location, so just hide it.
-            magnifier.dismiss()
+            return
         }
+
+        // If the flow reaches here, it means that the magnifier could not be placed at a specified
+        // position. We now need to hide it so it doesn't show up at an invalid location.
+        sourceCenterInRoot = Offset.Unspecified
+        magnifier?.dismiss()
     }
 
     private fun updateSizeIfNecessary() {
@@ -425,19 +463,14 @@
 
     override fun ContentDrawScope.draw() {
         drawContent()
-        // don't update the magnifier immediately, actual frame draw happens right after all draw
-        // commands are recorded. Magnifier update should happen in the next frame.
-        coroutineScope.launch {
-            withFrameMillis { }
-            magnifier?.updateContent()
-        }
+        drawSignalChannel?.trySend(Unit)
     }
 
     override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
         // The mutable state must store the Offset, not the LocalCoordinates, because the same
         // LocalCoordinates instance may be sent to this callback multiple times, not implement
         // equals, or be stable, and so won't invalidate the snapshotFlow.
-        anchorPositionInRoot = coordinates.positionInRoot()
+        layoutCoordinates = coordinates
     }
 
     override fun SemanticsPropertyReceiver.applySemantics() {
@@ -448,3 +481,13 @@
 @ChecksSdkIntAtLeast(api = 28)
 internal fun isPlatformMagnifierSupported(sdkVersion: Int = Build.VERSION.SDK_INT) =
     sdkVersion >= 28
+
+/**
+ * Normally `Float.NaN == Float.NaN` returns false but we use [Float.NaN] to mean Unspecified.
+ * The comparison between two unspecified values should return _equal_ if we are only interested
+ * in state changes.
+ */
+internal fun Float.equalsIncludingNaN(other: Float): Boolean {
+    if (this.isNaN() && other.isNaN()) return true
+    return this == other
+}
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
index 1bdf683..a53b03d 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/EditorInfo.android.kt
@@ -203,5 +203,11 @@
             InsertGesture::class.java,
             RemoveSpaceGesture::class.java
         )
+        editorInfo.supportedHandwritingGesturePreviews = setOf(
+            SelectGesture::class.java,
+            DeleteGesture::class.java,
+            SelectRangeGesture::class.java,
+            DeleteRangeGesture::class.java
+        )
     }
 }
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
index d47c722..ba0422e 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/HandwritingGesture.android.kt
@@ -421,6 +421,30 @@
     }
 
     @DoNotInline
+    internal fun LegacyTextFieldState.previewHandwritingGesture(
+        gesture: PreviewableHandwritingGesture,
+        textFieldSelectionManager: TextFieldSelectionManager?,
+        cancellationSignal: CancellationSignal?
+    ): Boolean {
+        val text = untransformedText ?: return false
+        if (text != layoutResult?.value?.layoutInput?.text) {
+            // The text is transformed or layout is null, handwriting gesture failed.
+            return false
+        }
+        when (gesture) {
+            is SelectGesture -> previewSelectGesture(gesture, textFieldSelectionManager)
+            is DeleteGesture -> previewDeleteGesture(gesture, textFieldSelectionManager)
+            is SelectRangeGesture -> previewSelectRangeGesture(gesture, textFieldSelectionManager)
+            is DeleteRangeGesture -> previewDeleteRangeGesture(gesture, textFieldSelectionManager)
+            else -> return false
+        }
+        cancellationSignal?.setOnCancelListener {
+            textFieldSelectionManager?.clearPreviewHighlight()
+        }
+        return true
+    }
+
+    @DoNotInline
     private fun LegacyTextFieldState.performSelectGesture(
         gesture: SelectGesture,
         textSelectionManager: TextFieldSelectionManager?,
@@ -439,6 +463,20 @@
     }
 
     @DoNotInline
+    private fun LegacyTextFieldState.previewSelectGesture(
+        gesture: SelectGesture,
+        textFieldSelectionManager: TextFieldSelectionManager?
+    ) {
+        textFieldSelectionManager?.setSelectionPreviewHighlight(
+            getRangeForScreenRect(
+                gesture.selectionArea.toComposeRect(),
+                gesture.granularity.toTextGranularity(),
+                TextInclusionStrategy.ContainsCenter
+            )
+        )
+    }
+
+    @DoNotInline
     private fun LegacyTextFieldState.performDeleteGesture(
         gesture: DeleteGesture,
         text: AnnotatedString,
@@ -463,6 +501,20 @@
     }
 
     @DoNotInline
+    private fun LegacyTextFieldState.previewDeleteGesture(
+        gesture: DeleteGesture,
+        textFieldSelectionManager: TextFieldSelectionManager?
+    ) {
+        textFieldSelectionManager?.setDeletionPreviewHighlight(
+            getRangeForScreenRect(
+                gesture.deletionArea.toComposeRect(),
+                gesture.granularity.toTextGranularity(),
+                TextInclusionStrategy.ContainsCenter
+            )
+        )
+    }
+
+    @DoNotInline
     private fun LegacyTextFieldState.performSelectRangeGesture(
         gesture: SelectRangeGesture,
         textSelectionManager: TextFieldSelectionManager?,
@@ -486,6 +538,21 @@
     }
 
     @DoNotInline
+    private fun LegacyTextFieldState.previewSelectRangeGesture(
+        gesture: SelectRangeGesture,
+        textFieldSelectionManager: TextFieldSelectionManager?
+    ) {
+        textFieldSelectionManager?.setSelectionPreviewHighlight(
+            getRangeForScreenRects(
+                gesture.selectionStartArea.toComposeRect(),
+                gesture.selectionEndArea.toComposeRect(),
+                gesture.granularity.toTextGranularity(),
+                TextInclusionStrategy.ContainsCenter
+            )
+        )
+    }
+
+    @DoNotInline
     private fun LegacyTextFieldState.performDeleteRangeGesture(
         gesture: DeleteRangeGesture,
         text: AnnotatedString,
@@ -510,6 +577,21 @@
     }
 
     @DoNotInline
+    private fun LegacyTextFieldState.previewDeleteRangeGesture(
+        gesture: DeleteRangeGesture,
+        textFieldSelectionManager: TextFieldSelectionManager?
+    ) {
+        textFieldSelectionManager?.setDeletionPreviewHighlight(
+            getRangeForScreenRects(
+                gesture.deletionStartArea.toComposeRect(),
+                gesture.deletionEndArea.toComposeRect(),
+                gesture.granularity.toTextGranularity(),
+                TextInclusionStrategy.ContainsCenter
+            )
+        )
+    }
+
+    @DoNotInline
     private fun LegacyTextFieldState.performJoinOrSplitGesture(
         gesture: JoinOrSplitGesture,
         text: AnnotatedString,
diff --git a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
index 4d897e0..34e71cb 100644
--- a/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
+++ b/compose/foundation/foundation/src/androidMain/kotlin/androidx/compose/foundation/text/input/internal/RecordingInputConnection.android.kt
@@ -18,6 +18,7 @@
 
 import android.os.Build
 import android.os.Bundle
+import android.os.CancellationSignal
 import android.os.Handler
 import android.text.TextUtils
 import android.util.Log
@@ -30,10 +31,12 @@
 import android.view.inputmethod.HandwritingGesture
 import android.view.inputmethod.InputConnection
 import android.view.inputmethod.InputContentInfo
+import android.view.inputmethod.PreviewableHandwritingGesture
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.compose.foundation.text.LegacyTextFieldState
 import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.performHandwritingGesture
+import androidx.compose.foundation.text.input.internal.HandwritingGestureApi34.previewHandwritingGesture
 import androidx.compose.foundation.text.selection.TextFieldSelectionManager
 import androidx.compose.ui.platform.ViewConfiguration
 import androidx.compose.ui.text.input.CommitTextCommand
@@ -426,6 +429,22 @@
         }
     }
 
+    override fun previewHandwritingGesture(
+        gesture: PreviewableHandwritingGesture,
+        cancellationSignal: CancellationSignal?
+    ): Boolean {
+        if (DEBUG) { logDebug("previewHandwritingGesture($gesture, $cancellationSignal)") }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            return Api34LegacyPerformHandwritingGestureImpl.previewHandwritingGesture(
+                legacyTextFieldState,
+                textFieldSelectionManager,
+                gesture,
+                cancellationSignal
+            )
+        }
+        return false
+    }
+
     // endregion
     // region Unsupported callbacks
 
@@ -533,4 +552,18 @@
             consumer.accept(result)
         }
     }
+
+    @DoNotInline
+    fun previewHandwritingGesture(
+        legacyTextFieldState: LegacyTextFieldState?,
+        textFieldSelectionManager: TextFieldSelectionManager?,
+        gesture: PreviewableHandwritingGesture,
+        cancellationSignal: CancellationSignal?
+    ): Boolean {
+        return legacyTextFieldState?.previewHandwritingGesture(
+            gesture,
+            textFieldSelectionManager,
+            cancellationSignal
+        ) ?: false
+    }
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
index 612ba62..0d1bb80 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutItemAnimator.kt
@@ -241,6 +241,7 @@
                     lane = info.lane,
                     span = info.span
                 )
+
                 item.nonScrollableItem = true
                 // check if we have any active placement animation on the item
                 val inProgress =
@@ -248,6 +249,14 @@
                 if ((!inProgress && newIndex == previousKeyToIndexMap?.getIndex(key))) {
                     removeInfoForKey(key)
                 } else {
+                    // anytime we compose a new item, and we use it,
+                    // we need to update our item info mapping
+                    info.updateAnimation(
+                        item,
+                        coroutineScope,
+                        graphicsContext,
+                        crossAxisOffset = info.crossAxisOffset
+                    )
                     if (newIndex < firstVisibleIndex) {
                         movingAwayToStartBound.add(item)
                     } else {
@@ -444,7 +453,8 @@
         fun updateAnimation(
             positionedItem: T,
             coroutineScope: CoroutineScope,
-            graphicsContext: GraphicsContext
+            graphicsContext: GraphicsContext,
+            crossAxisOffset: Int = positionedItem.crossAxisOffset
         ) {
             for (i in positionedItem.placeablesCount until animations.size) {
                 animations[i]?.release()
@@ -453,7 +463,7 @@
                 animations = animations.copyOf(positionedItem.placeablesCount)
             }
             constraints = positionedItem.constraints
-            crossAxisOffset = positionedItem.crossAxisOffset
+            this.crossAxisOffset = crossAxisOffset
             lane = positionedItem.lane
             span = positionedItem.span
             repeat(positionedItem.placeablesCount) { index ->
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
index c163320..00dd988 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicTextField.kt
@@ -325,11 +325,9 @@
         .scrollable(
             state = scrollState,
             orientation = orientation,
-            // Disable scrolling when textField is disabled, there is no where to scroll, and
-            // another dragging gesture is taking place
-            enabled = enabled &&
-                scrollState.maxValue > 0 &&
-                textFieldSelectionState.draggingHandle == null,
+            // Disable scrolling when textField is disabled or another dragging gesture is taking
+            // place
+            enabled = enabled && textFieldSelectionState.draggingHandle == null,
             reverseDirection = ScrollableDefaults.reverseDirection(
                 layoutDirection = layoutDirection,
                 orientation = orientation,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
index 1338218..c8f3fc3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreTextField.kt
@@ -434,9 +434,12 @@
                 TextFieldDelegate.draw(
                     canvas,
                     value,
+                    state.selectionPreviewHighlightRange,
+                    state.deletionPreviewHighlightRange,
                     offsetMapping,
                     layoutResult.value,
-                    state.selectionPaint
+                    state.highlightPaint,
+                    state.selectionBackgroundColor
                 )
             }
         }
@@ -614,7 +617,7 @@
         }
     }
 
-    val showCursor = enabled && !readOnly && windowInfo.isWindowFocused
+    val showCursor = enabled && !readOnly && windowInfo.isWindowFocused && !state.hasHighlight()
     val cursorModifier = Modifier.cursor(state, value, offsetMapping, cursorBrush, showCursor)
 
     DisposableEffect(manager) {
@@ -976,6 +979,8 @@
             // Text has been changed, enter the HandleState.None and hide the cursor handle.
             handleState = HandleState.None
         }
+        selectionPreviewHighlightRange = TextRange.Zero
+        deletionPreviewHighlightRange = TextRange.Zero
         onValueChangeOriginal(it)
         recomposeScope.invalidate()
     }
@@ -984,8 +989,16 @@
         keyboardActionRunner.runAction(imeAction)
     }
 
-    /** The paint used to draw highlight background for selected text. */
-    val selectionPaint: Paint = Paint()
+    /** The paint used to draw highlight backgrounds. */
+    val highlightPaint: Paint = Paint()
+    var selectionBackgroundColor = Color.Unspecified
+
+    /** Range of text to be highlighted to display handwriting gesture previews from the IME. */
+    var selectionPreviewHighlightRange: TextRange by mutableStateOf(TextRange.Zero)
+    var deletionPreviewHighlightRange: TextRange by mutableStateOf(TextRange.Zero)
+
+    fun hasHighlight() =
+        !selectionPreviewHighlightRange.collapsed || !deletionPreviewHighlightRange.collapsed
 
     fun update(
         untransformedText: AnnotatedString,
@@ -1000,7 +1013,7 @@
         selectionBackgroundColor: Color
     ) {
         this.onValueChangeOriginal = onValueChange
-        this.selectionPaint.color = selectionBackgroundColor
+        this.selectionBackgroundColor = selectionBackgroundColor
         this.keyboardActionRunner.apply {
             this.keyboardActions = keyboardActions
             this.focusManager = focusManager
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
index b6567d5..eabeb8e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.kt
@@ -23,7 +23,9 @@
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Canvas
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.isUnspecified
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.findRootCoordinates
 import androidx.compose.ui.text.AnnotatedString
@@ -112,28 +114,75 @@
          *
          * @param canvas The target canvas.
          * @param value The editor state
+         * @param selectionPreviewHighlightRange Range to be highlighted to preview a handwriting
+         *     selection gesture
+         * @param deletionPreviewHighlightRange Range to be highlighted to preview a handwriting
+         *     deletion gesture
          * @param offsetMapping The offset map
-         * @param selectionPaint The selection paint
+         * @param textLayoutResult The text layout result
+         * @param highlightPaint Paint used to draw highlight backgrounds
+         * @param selectionBackgroundColor The selection highlight background color
          */
         @JvmStatic
         internal fun draw(
             canvas: Canvas,
             value: TextFieldValue,
+            selectionPreviewHighlightRange: TextRange,
+            deletionPreviewHighlightRange: TextRange,
             offsetMapping: OffsetMapping,
             textLayoutResult: TextLayoutResult,
-            selectionPaint: Paint
+            highlightPaint: Paint,
+            selectionBackgroundColor: Color
         ) {
-            if (!value.selection.collapsed) {
-                val start = offsetMapping.originalToTransformed(value.selection.min)
-                val end = offsetMapping.originalToTransformed(value.selection.max)
-                if (start != end) {
-                    val selectionPath = textLayoutResult.getPathForRange(start, end)
-                    canvas.drawPath(selectionPath, selectionPaint)
-                }
+            if (!selectionPreviewHighlightRange.collapsed) {
+                highlightPaint.color = selectionBackgroundColor
+                drawHighlight(
+                    canvas,
+                    selectionPreviewHighlightRange,
+                    offsetMapping,
+                    textLayoutResult,
+                    highlightPaint
+                )
+            } else if (!deletionPreviewHighlightRange.collapsed) {
+                val textColor =
+                    textLayoutResult.layoutInput.style.color.takeUnless { it.isUnspecified }
+                        ?: Color.Black
+                highlightPaint.color = textColor.copy(alpha = textColor.alpha * 0.2f)
+                drawHighlight(
+                    canvas,
+                    deletionPreviewHighlightRange,
+                    offsetMapping,
+                    textLayoutResult,
+                    highlightPaint
+                )
+            } else if (!value.selection.collapsed) {
+                highlightPaint.color = selectionBackgroundColor
+                drawHighlight(
+                    canvas,
+                    value.selection,
+                    offsetMapping,
+                    textLayoutResult,
+                    highlightPaint
+                )
             }
             TextPainter.paint(canvas, textLayoutResult)
         }
 
+        private fun drawHighlight(
+            canvas: Canvas,
+            range: TextRange,
+            offsetMapping: OffsetMapping,
+            textLayoutResult: TextLayoutResult,
+            paint: Paint
+        ) {
+            val start = offsetMapping.originalToTransformed(range.min)
+            val end = offsetMapping.originalToTransformed(range.max)
+            if (start != end) {
+                val selectionPath = textLayoutResult.getPathForRange(start, end)
+                canvas.drawPath(selectionPath, paint)
+            }
+        }
+
         /**
          * Notify system that focused input area.
          *
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
index 7cf3de1..fe8166b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldMagnifier.kt
@@ -98,7 +98,14 @@
     // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
     // magnifier actually is). See
     // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
-    if ((dragX - centerX).absoluteValue > magnifierSize.width / 2) {
+    // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+    // created until it's requested for the first time. So the size will only be calculated after we
+    // return a specified offset from this function.
+    // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+    // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+    // of magnifier's width to hide by the following logic.
+    if (magnifierSize != IntSize.Zero &&
+        (dragX - centerX).absoluteValue > magnifierSize.width / 2) {
         return Offset.Unspecified
     }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
index 4cb66ac..465fc46 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.kt
@@ -1034,7 +1034,14 @@
     // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
     // magnifier actually is). See
     // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
-    if ((dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
+    // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+    // created until it's requested for the first time. So the size will only be calculated after we
+    // return a specified offset from this function.
+    // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+    // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+    // of magnifier's width to hide by the following logic.
+    if (magnifierSize != IntSize.Zero &&
+        (dragX - textConstrainedX).absoluteValue > magnifierSize.width / 2) {
         return Offset.Unspecified
     }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
index bb5b445..a32cf68 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.kt
@@ -576,6 +576,23 @@
         updateFloatingToolbar(show = false)
     }
 
+    internal fun setSelectionPreviewHighlight(range: TextRange) {
+        state?.selectionPreviewHighlightRange = range
+        state?.deletionPreviewHighlightRange = TextRange.Zero
+        if (!range.collapsed) exitSelectionMode()
+    }
+
+    internal fun setDeletionPreviewHighlight(range: TextRange) {
+        state?.deletionPreviewHighlightRange = range
+        state?.selectionPreviewHighlightRange = TextRange.Zero
+        if (!range.collapsed) exitSelectionMode()
+    }
+
+    internal fun clearPreviewHighlight() {
+        state?.deletionPreviewHighlightRange = TextRange.Zero
+        state?.selectionPreviewHighlightRange = TextRange.Zero
+    }
+
     /**
      * The method for copying text.
      *
@@ -1036,7 +1053,14 @@
     // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
     // magnifier actually is). See
     // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
-    if ((dragX - centerX).absoluteValue > magnifierSize.width / 2) {
+    // Also check whether magnifierSize is calculated. A platform magnifier instance is not
+    // created until it's requested for the first time. So the size will only be calculated after we
+    // return a specified offset from this function.
+    // It is very unlikely that this behavior would cause a flicker since magnifier immediately
+    // shows up where the pointer is being dragged. The pointer needs to drag further than the half
+    // of magnifier's width to hide by the following logic.
+    if (magnifierSize != IntSize.Zero &&
+        (dragX - centerX).absoluteValue > magnifierSize.width / 2) {
         return Offset.Unspecified
     }
 
diff --git a/compose/ui/ui-graphics/api/current.txt b/compose/ui/ui-graphics/api/current.txt
index 515e4f5..41516b7 100644
--- a/compose/ui/ui-graphics/api/current.txt
+++ b/compose/ui/ui-graphics/api/current.txt
@@ -918,7 +918,7 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.graphics.Shadow lerp(androidx.compose.ui.graphics.Shadow start, androidx.compose.ui.graphics.Shadow stop, float fraction);
   }
 
-  @androidx.compose.runtime.Immutable public interface Shape {
+  @androidx.compose.runtime.Stable public interface Shape {
     method public androidx.compose.ui.graphics.Outline createOutline(long size, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density);
   }
 
diff --git a/compose/ui/ui-graphics/api/restricted_current.txt b/compose/ui/ui-graphics/api/restricted_current.txt
index 68a8faa..9da68f8 100644
--- a/compose/ui/ui-graphics/api/restricted_current.txt
+++ b/compose/ui/ui-graphics/api/restricted_current.txt
@@ -990,7 +990,7 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.graphics.Shadow lerp(androidx.compose.ui.graphics.Shadow start, androidx.compose.ui.graphics.Shadow stop, float fraction);
   }
 
-  @androidx.compose.runtime.Immutable public interface Shape {
+  @androidx.compose.runtime.Stable public interface Shape {
     method public androidx.compose.ui.graphics.Outline createOutline(long size, androidx.compose.ui.unit.LayoutDirection layoutDirection, androidx.compose.ui.unit.Density density);
   }
 
diff --git a/compose/ui/ui-graphics/build.gradle b/compose/ui/ui-graphics/build.gradle
index 6b1601a..8046e187 100644
--- a/compose/ui/ui-graphics/build.gradle
+++ b/compose/ui/ui-graphics/build.gradle
@@ -76,7 +76,9 @@
                 // This has stub APIs for access to legacy Android APIs, so we don't want
                 // any dependency on this module.
                 compileOnly(project(":compose:ui:ui-android-stubs"))
-                implementation("androidx.graphics:graphics-path:1.0.0-beta02")
+                // TODO: Re-pin when 1.0.1 is released
+                //implementation("androidx.graphics:graphics-path:1.0.1")
+                implementation(project(":graphics:graphics-path"))
                 implementation libs.androidx.core
                 api("androidx.annotation:annotation-experimental:1.4.0")
             }
diff --git a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
index 776c8d5..c106240 100644
--- a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
+++ b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/OutlineTest.kt
@@ -22,6 +22,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -55,4 +56,44 @@
         )
         assertEquals(Rect(0f, 15f, 100f, 200f), pathOutline.bounds)
     }
+
+    @Test
+    fun testRectOutlineEquality() {
+        val outlineRect = Outline.Rectangle(Rect(1f, 2f, 3f, 4f))
+        val equalOutlineRect = Outline.Rectangle(Rect(1f, 2f, 3f, 4f))
+        val differentOutlineRect = Outline.Rectangle(Rect(4f, 3f, 2f, 1f))
+        assertEquals(outlineRect, equalOutlineRect)
+        assertNotEquals(outlineRect, differentOutlineRect)
+    }
+
+    @Test
+    fun testRoundRectOutlineEquality() {
+        val roundRectOutline = Outline.Rounded(
+            RoundRect(5f, 10f, 15f, 20f, CornerRadius(7f))
+        )
+        val equalRoundRectOutline = Outline.Rounded(
+            RoundRect(5f, 10f, 15f, 20f, CornerRadius(7f))
+        )
+        val differentRoundRectOutline = Outline.Rounded(
+            RoundRect(20f, 15f, 10f, 5f, CornerRadius(3f))
+        )
+        assertEquals(roundRectOutline, equalRoundRectOutline)
+        assertNotEquals(roundRectOutline, differentRoundRectOutline)
+    }
+
+    @Test
+    fun testPathOutlineEquality() {
+        val path = Path().apply {
+            moveTo(5f, 15f)
+            lineTo(100f, 200f)
+            lineTo(0f, 200f)
+            close()
+        }
+        val pathOutline = Outline.Generic(path)
+        val pathOutline2 = Outline.Generic(path)
+
+        // Generic outlines should only be referentially equal, as the path can change over time
+        assertEquals(pathOutline, pathOutline)
+        assertNotEquals(pathOutline, pathOutline2)
+    }
 }
diff --git a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
index bf3b965..c6dc2dc 100644
--- a/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
+++ b/compose/ui/ui-graphics/src/androidInstrumentedTest/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayerTest.kt
@@ -21,9 +21,6 @@
 import android.graphics.PixelFormat
 import android.graphics.drawable.Drawable
 import android.os.Build
-import android.os.Handler
-import android.os.HandlerThread
-import android.os.Looper
 import android.view.View
 import android.view.ViewGroup
 import android.widget.FrameLayout
@@ -51,7 +48,6 @@
 import androidx.compose.ui.graphics.drawscope.inset
 import androidx.compose.ui.graphics.drawscope.translate
 import androidx.compose.ui.graphics.nativeCanvas
-import androidx.compose.ui.graphics.throwIllegalArgumentException
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.graphics.toPixelMap
 import androidx.compose.ui.unit.Density
@@ -71,10 +67,7 @@
 import java.util.concurrent.TimeUnit
 import kotlin.math.roundToInt
 import kotlin.test.assertNotNull
-import kotlinx.coroutines.android.asCoroutineDispatcher
 import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import org.junit.After
 import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
@@ -92,61 +85,12 @@
         val TEST_SIZE = IntSize(TEST_WIDTH, TEST_HEIGHT)
     }
 
-    private var waitThread: HandlerThread? = null
-    private var waitHandler: Handler? = null
-
-    private fun obtainWaitHandler(): Handler {
-        synchronized(this) {
-            var thread = waitThread
-            if (thread == null) {
-                thread = HandlerThread("waitThread").also {
-                    it.start()
-                    waitThread = it
-                }
-            }
-
-            var handler = waitHandler
-            if (handler == null) {
-                handler = Handler(thread.looper)
-            }
-            return handler
-        }
-    }
-
-    /**
-     * Helper method used to synchronously obtain an [ImageBitmap] from a [GraphicsLayer]
-     */
-    private fun GraphicsLayer.toImageBitmap(handler: Handler): ImageBitmap {
-        if (Looper.myLooper() === handler.looper) {
-            throwIllegalArgumentException("Handler looper cannot be the same as the current " +
-                "looper: ${Looper.myLooper()?.thread?.name}  ${handler.looper.thread.name}")
-        }
-        val latch = CountDownLatch(1)
-        var bitmap: ImageBitmap?
-        runBlocking {
-            withContext(handler.asCoroutineDispatcher().immediate) {
-                bitmap = toImageBitmap()
-                latch.countDown()
-            }
-            latch.await()
-        }
-        return bitmap!!
-    }
-
-    @After
-    fun teardown() {
-        synchronized(this) {
-            waitThread?.quit()
-            waitThread = null
-        }
-    }
-
     @Test
     fun testGraphicsLayerBitmap() {
-        var bitmap: ImageBitmap? = null
+        lateinit var layer: GraphicsLayer
         graphicsLayerTest(
             block = { graphicsContext ->
-                graphicsContext.createGraphicsLayer().apply {
+                layer = graphicsContext.createGraphicsLayer().apply {
                     assertEquals(IntSize.Zero, this.size)
                     record {
                         drawRect(
@@ -169,13 +113,13 @@
                             size = size / 2f
                         )
                     }
-                    bitmap = toImageBitmap(obtainWaitHandler())
                 }
             },
             verify = {
+                val bitmap: ImageBitmap = layer.toImageBitmap()
                 assertNotNull(bitmap)
-                assertEquals(TEST_SIZE, IntSize(bitmap!!.width, bitmap!!.height))
-                bitmap!!.toPixelMap().verifyQuadrants(
+                assertEquals(TEST_SIZE, IntSize(bitmap.width, bitmap.height))
+                bitmap.toPixelMap().verifyQuadrants(
                     Color.Red,
                     Color.Blue,
                     Color.Green,
@@ -206,7 +150,7 @@
                 // Nulling out the dependency here should be safe despite attempting to obtain an
                 // ImageBitmap afterwards
                 provider = null
-                graphicsLayer!!.toImageBitmap(obtainWaitHandler()).toPixelMap().verifyQuadrants(
+                graphicsLayer!!.toImageBitmap().toPixelMap().verifyQuadrants(
                     Color.Red,
                     Color.Red,
                     Color.Red,
@@ -1290,7 +1234,9 @@
                 val path = Path().also { it.addOval(Rect(1f, 2f, 3f, 4f)) }
                 val generic = Outline.Generic(path)
                 layer.setOutline(generic)
-                assertEquals(generic, layer.outline)
+                // We wrap the path in a different Outline object from what we pass in, so compare
+                // the paths instead of the outline instances
+                assertEquals(generic.path, (layer.outline as Outline.Generic).path)
             }
         )
     }
@@ -1355,7 +1301,7 @@
 
     private fun graphicsLayerTest(
         block: DrawScope.(GraphicsContext) -> Unit,
-        verify: ((PixelMap) -> Unit)? = null,
+        verify: (suspend (PixelMap) -> Unit)? = null,
         entireScene: Boolean = false,
         usePixelCopy: Boolean = false
     ) {
@@ -1399,7 +1345,7 @@
                         resumed.countDown()
                     }
                 }
-            Assert.assertTrue(resumed.await(300000, TimeUnit.MILLISECONDS))
+            assertTrue(resumed.await(3000, TimeUnit.MILLISECONDS))
 
             if (verify != null) {
                 val target = if (entireScene) {
@@ -1407,8 +1353,8 @@
                 } else {
                     contentView!!
                 }
-                if (usePixelCopy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-                    verify(target.captureToImage().toPixelMap())
+                val pixelMap = if (usePixelCopy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+                    target.captureToImage().toPixelMap()
                 } else {
                     val recordLatch = CountDownLatch(1)
                     testActivity!!.runOnUiThread {
@@ -1423,11 +1369,14 @@
                         }
                         recordLatch.countDown()
                     }
-                    assertTrue(recordLatch.await(30000, TimeUnit.MILLISECONDS))
+                    assertTrue(recordLatch.await(3000, TimeUnit.MILLISECONDS))
                     val bitmap = runBlocking {
                         rootGraphicsLayer!!.toImageBitmap()
                     }
-                    verify(bitmap.toPixelMap())
+                    bitmap.toPixelMap()
+                }
+                runBlocking {
+                    verify(pixelMap)
                 }
             }
         } finally {
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
index 330d8ec..7d30041 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/AndroidGraphicsLayer.android.kt
@@ -472,6 +472,10 @@
         androidCanvas.concat(impl.calculateMatrix())
     }
 
+    internal fun drawForPersistence(canvas: Canvas) {
+        impl.draw(canvas)
+    }
+
     /**
      * Draw the contents of this [GraphicsLayer] into the specified [Canvas]
      */
diff --git a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
index 23b14d9..f1f1132 100644
--- a/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
+++ b/compose/ui/ui-graphics/src/androidMain/kotlin/androidx/compose/ui/graphics/layer/LayerManager.android.kt
@@ -19,11 +19,13 @@
 import android.graphics.PixelFormat
 import android.media.ImageReader
 import android.os.Build
+import android.os.Looper
 import android.view.Surface
 import androidx.annotation.RequiresApi
 import androidx.collection.ObjectList
 import androidx.collection.mutableObjectListOf
 import androidx.compose.ui.graphics.CanvasHolder
+import androidx.core.os.HandlerCompat
 
 /**
  * Class responsible for managing the layer lifecycle to support
@@ -44,10 +46,17 @@
      */
     private var imageReader: ImageReader? = null
 
+    private val handler = HandlerCompat.createAsync(Looper.getMainLooper()) {
+        persistLayers(layerList)
+        true
+    }
+
     fun persist(layer: GraphicsLayer) {
         if (!layerList.contains(layer)) {
             layerList.add(layer)
-            persistLayers(layerList)
+            if (!handler.hasMessages(0)) {
+                handler.sendEmptyMessage(0)
+            }
         }
     }
 
@@ -65,7 +74,7 @@
          * another internal CanvasContext instance owned by the internal HwuiContext instance of
          * a Surface. This is only necessary for Android M and above.
          */
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && layers.isNotEmpty()) {
             val reader = imageReader ?: ImageReader.newInstance(
                 1,
                 1,
@@ -78,7 +87,7 @@
             // are not supported
             if (canvas.isHardwareAccelerated) {
                 canvasHolder.drawInto(canvas) {
-                    layers.forEach { layer -> layer.draw(this, null) }
+                    layers.forEach { layer -> layer.drawForPersistence(this) }
                 }
             }
             surface.unlockCanvasAndPost(canvas)
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
index b3c7de3..0c20ed5 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Outline.kt
@@ -106,18 +106,8 @@
         override val bounds: Rect
             get() = path.getBounds()
 
-        override fun equals(other: Any?): Boolean {
-            if (this === other) return true
-            if (other !is Generic) return false
-
-            if (path != other.path) return false
-
-            return true
-        }
-
-        override fun hashCode(): Int {
-            return path.hashCode()
-        }
+        // No equals or hashcode, two different outlines using the same path shouldn't be considered
+        // equal as the path may have changed since the previous outline was rendered
     }
 
     /**
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
index e174944..0ce177b 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Shape.kt
@@ -16,7 +16,7 @@
 
 package androidx.compose.ui.graphics
 
-import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.LayoutDirection
@@ -24,7 +24,7 @@
 /**
  * Defines a generic shape.
  */
-@Immutable
+@Stable
 interface Shape {
     /**
      * Creates [Outline] of this shape for the given [size].
diff --git a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
index 71d02be..2f412d9 100644
--- a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
+++ b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyAndMouseEventsTest.kt
@@ -438,7 +438,8 @@
             Offset.Zero, 0, expectedMetaState = expectedMetaState)
 
         // Key Toggle Off
-        recorder.events[8].verifyKeyEvent(keyDown, key.nativeKeyCode)
+        recorder.events[8].verifyKeyEvent(keyDown, key.nativeKeyCode,
+            expectedMetaState = expectedMetaState)
         recorder.events[9].verifyKeyEvent(keyUp, key.nativeKeyCode)
 
         // Mouse Press
diff --git a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
index 0216a50..4d1f794 100644
--- a/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
+++ b/compose/ui/ui-test/src/androidUnitTest/kotlin/androidx/compose/ui/test/inputdispatcher/KeyEventsTest.kt
@@ -514,7 +514,8 @@
         recorder.events[1].verifyKeyEvent(
             keyUp, key.nativeKeyCode, expectedMetaState = expectedMetaState
         )
-        recorder.events[2].verifyKeyEvent(keyDown, key.nativeKeyCode)
+        recorder.events[2].verifyKeyEvent(keyDown, key.nativeKeyCode,
+            expectedMetaState = expectedMetaState)
         recorder.events[3].verifyKeyEvent(keyUp, key.nativeKeyCode)
     }
 }
diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
index 1723384..1d17c65 100644
--- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
+++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InputDispatcher.kt
@@ -705,6 +705,30 @@
 
     protected abstract fun KeyInputState.enqueueUp(key: Key)
 
+    /**
+     * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+     * toggling. To change toggling behaviour, override this method and switch to using
+     * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+     */
+    protected open val KeyInputState.capsLockOn: Boolean
+        get() = capsLockState.isLockKeyOnIncludingOffPress
+
+    /**
+     * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+     * toggling. To change toggling behaviour, override this method and switch to using
+     * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+     */
+    protected open val KeyInputState.numLockOn: Boolean
+        get() = numLockState.isLockKeyOnIncludingOffPress
+
+    /**
+     * Used to control lock key toggling behaviour on different platforms. Defaults to Android-style
+     * toggling. To change toggling behaviour, override this method and switch to using
+     * [LockKeyState.isLockKeyOnExcludingOffPress], or implement a different toggling behaviour.
+     */
+    protected open val KeyInputState.scrollLockOn: Boolean
+        get() = scrollLockState.isLockKeyOnIncludingOffPress
+
     @OptIn(ExperimentalTestApi::class)
     protected abstract fun MouseInputState.enqueueScroll(delta: Float, scrollWheel: ScrollWheel)
 
@@ -778,6 +802,56 @@
 }
 
 /**
+ * Toggling states for lock keys.
+ *
+ * Note that lock keys may not be toggled in the same way across all platforms.
+ *
+ * Take caps lock as an example; consistently, all platforms turn caps lock on upon the first
+ * key down event, and it stays on after the subsequent key up. However, on some platforms caps
+ * lock will turn off immediately upon the next key down event (MacOS for example), whereas
+ * other platforms (e.g. Linux, Android) wait for the next key up event before turning caps
+ * lock off.
+ *
+ * This enum breaks the lock key state down into four possible options - depending upon the
+ * interpretation of these four states, Android-like or MacOS-like behaviour can both be achieved.
+ *
+ * To get Android-like behaviour, use [isLockKeyOnIncludingOffPress],
+ * whereas for MacOS-style behaviour, use [isLockKeyOnExcludingOffPress].
+ */
+internal enum class LockKeyState(val state: Int) {
+    UP_AND_OFF(0),
+    DOWN_AND_ON(1),
+    UP_AND_ON(2),
+    DOWN_AND_OPTIONAL(3);
+
+    /**
+     * Whether or not the lock key is on. The lock key is considered on from the start of the
+     * "on press" until the end of the "off press", i.e. from the first key down event to the
+     * second key up event of the corresponding lock key.
+     */
+    val isLockKeyOnIncludingOffPress get() = state > 0
+
+    /**
+     * Whether or not the lock key is on. The lock key is considered on from the start of the
+     * "on press" until the start of the "off press", i.e. from the first key down event to the
+     * second key down event of the corresponding lock key.
+     */
+    val isLockKeyOnExcludingOffPress get() = this == DOWN_AND_ON || this == UP_AND_ON
+
+    /**
+     * Returns the next state in the cycle of lock key states.
+     */
+    fun next(): LockKeyState {
+        return when (this) {
+            UP_AND_OFF -> DOWN_AND_ON
+            DOWN_AND_ON -> UP_AND_ON
+            UP_AND_ON -> DOWN_AND_OPTIONAL
+            DOWN_AND_OPTIONAL -> UP_AND_OFF
+        }
+    }
+}
+
+/**
  * The current key input state. Contains the keys that are pressed, the down time of the
  * keyboard (which is the time of the last key down event), the state of the lock keys and
  * the device ID.
@@ -789,9 +863,9 @@
     var repeatKey: Key? = null
     var repeatCount = 0
     var lastRepeatTime = downTime
-    var capsLockOn = false
-    var numLockOn = false
-    var scrollLockOn = false
+    var capsLockState: LockKeyState = LockKeyState.UP_AND_OFF
+    var numLockState: LockKeyState = LockKeyState.UP_AND_OFF
+    var scrollLockState: LockKeyState = LockKeyState.UP_AND_OFF
 
     fun isKeyDown(key: Key): Boolean = downKeys.contains(key)
 
@@ -801,6 +875,7 @@
             repeatKey = null
             repeatCount = 0
         }
+        updateLockKeys(key)
     }
 
     fun setKeyDown(key: Key) {
@@ -812,23 +887,12 @@
 
     /**
      * Updates lock key state values.
-     *
-     * Note that lock keys may not be toggled in the same way across all platforms.
-     *
-     * Take caps lock as an example; consistently, all platforms turn caps lock on upon the first
-     * key down event, and it stays on after the subsequent key up. However, on some platforms caps
-     * lock will turn off immediately upon the next key down event (MacOS for example), whereas
-     * other platforms (e.g. linux) wait for the next key up event before turning caps lock off.
-     *
-     * By calling this function whenever a lock key is pressed down, MacOS-like behaviour is
-     * achieved.
      */
-    // TODO(Onadim): Investigate how lock key toggling is handled in Android, ChromeOS and Windows.
     private fun updateLockKeys(key: Key) {
         when (key) {
-            Key.CapsLock -> capsLockOn = !capsLockOn
-            Key.NumLock -> numLockOn = !numLockOn
-            Key.ScrollLock -> scrollLockOn = !scrollLockOn
+            Key.CapsLock -> capsLockState = capsLockState.next()
+            Key.NumLock -> numLockState = numLockState.next()
+            Key.ScrollLock -> scrollLockState = scrollLockState.next()
         }
     }
 }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
index 2ca13db..ad00359 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/AndroidLayoutDrawTest.kt
@@ -396,7 +396,9 @@
             val scope = ReusableGraphicsLayerScope()
             scope.cameraDistance = cameraDistance
             scope.compositingStrategy = compositingStrategy
-            updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+            scope.layoutDirection = LayoutDirection.Ltr
+            scope.graphicsDensity = Density(1f)
+            updateLayerProperties(scope)
         }
         return expectedLayerType == view.layerType &&
             expectedOverlappingRendering == view.hasOverlappingRendering()
@@ -436,7 +438,9 @@
         ).apply {
             val scope = ReusableGraphicsLayerScope()
             scope.cameraDistance = cameraDistance
-            updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+            scope.layoutDirection = LayoutDirection.Ltr
+            scope.graphicsDensity = Density(1f)
+            updateLayerProperties(scope)
         }
         // Verify that the camera distance is applied properly even after accounting for
         // the internal dp conversion within View
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
index 6bc2204..490bb83 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/draw/ClipDrawTest.kt
@@ -47,6 +47,7 @@
 import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.PathOperation
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.addOutline
 import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.toArgb
@@ -471,6 +472,126 @@
 
     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
     @Test
+    fun switchBetweenDifferentOutlines_differentPath_observableShape() {
+        var invertedTriangle by mutableStateOf(false)
+        val observableShape = object : Shape {
+            override fun createOutline(
+                size: Size,
+                layoutDirection: LayoutDirection,
+                density: Density
+            ): Outline {
+              return if (invertedTriangle) {
+                  invertedTriangleShape.createOutline(size, layoutDirection, density)
+              } else {
+                  triangleShape.createOutline(size, layoutDirection, density)
+              }
+            }
+        }
+        // to be replaced with a DrawModifier wrapped into remember, so the recomposition
+        // is not causing invalidation as the DrawModifier didn't change
+        val drawCallback: DrawScope.() -> Unit = {
+            drawRect(
+                Color.Cyan,
+                topLeft = Offset(-100f, -100f),
+                size = Size(size.width + 200f, size.height + 200f)
+            )
+        }
+
+        val clip = Modifier.graphicsLayer {
+            shape = observableShape
+            clip = true
+            drawLatch.countDown()
+        }
+
+        rule.runOnUiThreadIR {
+            activity.setContent {
+                AtLeastSize(
+                    size = 30,
+                    modifier = Modifier
+                        .background(Color.Green)
+                        .then(clip)
+                        .drawBehind(drawCallback)
+                ) {
+                }
+            }
+        }
+
+        takeScreenShot(30).apply {
+            assertTriangle(Color.Cyan, Color.Green)
+        }
+
+        drawLatch = CountDownLatch(1)
+        rule.runOnUiThreadIR { invertedTriangle = true }
+
+        takeScreenShot(30).apply {
+            assertInvertedTriangle(Color.Cyan, Color.Green)
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
+    fun switchBetweenDifferentOutlines_samePath_observableShape() {
+        var invertedTriangle by mutableStateOf(false)
+        val path = Path()
+        val observableShape = object : Shape {
+            override fun createOutline(
+                size: Size,
+                layoutDirection: LayoutDirection,
+                density: Density
+            ): Outline {
+                val outline = if (invertedTriangle) {
+                    invertedTriangleShape.createOutline(size, layoutDirection, density)
+                } else {
+                    triangleShape.createOutline(size, layoutDirection, density)
+                }
+                path.reset()
+                path.addOutline(outline)
+                return Outline.Generic(path)
+            }
+        }
+        // to be replaced with a DrawModifier wrapped into remember, so the recomposition
+        // is not causing invalidation as the DrawModifier didn't change
+        val drawCallback: DrawScope.() -> Unit = {
+            drawRect(
+                Color.Cyan,
+                topLeft = Offset(-100f, -100f),
+                size = Size(size.width + 200f, size.height + 200f)
+            )
+        }
+
+        val clip = Modifier.graphicsLayer {
+            shape = observableShape
+            clip = true
+            drawLatch.countDown()
+        }
+
+        rule.runOnUiThreadIR {
+            activity.setContent {
+                AtLeastSize(
+                    size = 30,
+                    modifier = Modifier
+                        .background(Color.Green)
+                        .then(clip)
+                        .drawBehind(drawCallback)
+                ) {
+                }
+            }
+        }
+
+        takeScreenShot(30).apply {
+            assertTriangle(Color.Cyan, Color.Green)
+        }
+
+        drawLatch = CountDownLatch(1)
+        rule.runOnUiThreadIR { invertedTriangle = true }
+
+        takeScreenShot(30).apply {
+            assertInvertedTriangle(Color.Cyan, Color.Green)
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    @Test
     fun emitClipLater() {
         val model = mutableStateOf(false)
 
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index d1b40df..c63e2a9 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -4172,12 +4172,7 @@
         explicitLayer: GraphicsLayer?
     ): OwnedLayer {
         return object : OwnedLayer {
-            override fun updateLayerProperties(
-                scope: ReusableGraphicsLayerScope,
-                layoutDirection: LayoutDirection,
-                density: Density
-            ) {
-            }
+            override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {}
 
             override fun isInLayer(position: Offset) = true
 
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
index e8557cf..e0f3bf0 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/Helpers.kt
@@ -570,12 +570,7 @@
 
 internal open class MockLayer() : OwnedLayer {
 
-    override fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density
-    ) {
-    }
+    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {}
 
     override fun isInLayer(position: Offset) = true
 
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
index c2ec417..339d19f 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/node/NodeChainTester.kt
@@ -508,11 +508,7 @@
                 drawBlock: (Canvas, GraphicsLayer?) -> Unit,
                 invalidateParentLayer: () -> Unit
             ) {}
-            override fun updateLayerProperties(
-                scope: ReusableGraphicsLayerScope,
-                layoutDirection: LayoutDirection,
-                density: Density
-            ) {
+            override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
                 transform.reset()
                 // This is not expected to be 100% accurate
                 transform.scale(scope.scaleX, scope.scaleY)
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
index 12737fd..8399995 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/GraphicsLayerOwnerLayer.android.kt
@@ -28,9 +28,7 @@
 import androidx.compose.ui.graphics.Outline
 import androidx.compose.ui.graphics.Paint
 import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
-import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
 import androidx.compose.ui.graphics.drawscope.draw
@@ -80,7 +78,7 @@
     private val scope = CanvasDrawScope()
     private var mutatedFields: Int = 0
     private var transformOrigin: TransformOrigin = TransformOrigin.Center
-    private var shape: Shape = RectangleShape
+    private var outline: Outline? = null
     private var tmpPath: Path? = null
     /**
      * Optional paint used when the RenderNode is rendered on a software backed
@@ -88,17 +86,10 @@
      */
     private var softwareLayerPaint: Paint? = null
 
-    override fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density,
-    ) {
-        var maybeChangedFields = scope.mutatedFields or mutatedFields
-        if (this.layoutDirection != layoutDirection || this.density != density) {
-            this.layoutDirection = layoutDirection
-            this.density = density
-            maybeChangedFields = maybeChangedFields or Fields.Shape
-        }
+    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
+        val maybeChangedFields = scope.mutatedFields or mutatedFields
+        this.layoutDirection = scope.layoutDirection
+        this.density = scope.graphicsDensity
         if (maybeChangedFields and Fields.TransformOrigin != 0) {
             this.transformOrigin = scope.transformOrigin
         }
@@ -152,10 +143,6 @@
                 transformOrigin.pivotFractionY * size.height
             )
         }
-        if (maybeChangedFields and Fields.Shape != 0) {
-            this.shape = scope.shape
-            updateOutline()
-        }
         if (maybeChangedFields and Fields.Clip != 0) {
             graphicsLayer.clip = scope.clip
         }
@@ -171,8 +158,16 @@
             }
         }
 
+        var outlineChanged = false
+
+        if (outline != scope.outline) {
+            outlineChanged = true
+            outline = scope.outline
+            updateOutline()
+        }
+
         mutatedFields = scope.mutatedFields
-        if (maybeChangedFields != 0) {
+        if (maybeChangedFields != 0 || outlineChanged) {
             triggerRepaint()
         }
     }
@@ -189,7 +184,7 @@
     }
 
     private fun updateOutline() {
-        val outline = shape.createOutline(size.toSize(), layoutDirection, density)
+        val outline = outline ?: return
         graphicsLayer.setOutline(outline)
         if (outline is Outline.Generic && Build.VERSION.SDK_INT < 33) {
             // before 33 many of the paths are not clipping by rendernode. instead we have to
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
index 7f15fef..30d26a5 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/OutlineResolver.android.kt
@@ -27,17 +27,13 @@
 import androidx.compose.ui.graphics.Canvas
 import androidx.compose.ui.graphics.Outline
 import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.asAndroidPath
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.util.fastRoundToInt
 
 /**
- * Resolves the [AndroidOutline] from the [Shape] of an [OwnedLayer].
+ * Resolves the [AndroidOutline] from the [Outline] of an [androidx.compose.ui.node.OwnedLayer].
  */
-internal class OutlineResolver(private var density: Density) {
+internal class OutlineResolver {
 
     /**
      * Flag to determine if the shape specified on the outline is supported.
@@ -51,14 +47,9 @@
     private val cachedOutline = AndroidOutline().apply { alpha = 1f }
 
     /**
-     * The size of the layer. This is used in generating the [Outline] from the [Shape].
+     * The [Outline] of the Layer.
      */
-    private var size: Size = Size.Zero
-
-    /**
-     * The [Shape] of the Outline of the Layer.
-     */
-    private var shape: Shape = RectangleShape
+    private var outline: Outline? = null
 
     /**
      * Asymmetric rounded rectangles need to use a Path. This caches that Path so that
@@ -107,7 +98,7 @@
     /**
      * Returns the Android Outline to be used in the layer.
      */
-    val outline: AndroidOutline?
+    val androidOutline: AndroidOutline?
         get() {
             updateCache()
             return if (!outlineNeeded || !isSupportedOutline) null else cachedOutline
@@ -145,7 +136,6 @@
     /**
      * Returns the size for a rectangular, or rounded rect outline (regardless if it
      * is symmetric or asymmetric)
-     * For path based outlines this returns [Size.Zero]
      */
     private var rectSize: Size = Size.Zero
 
@@ -154,43 +144,32 @@
      */
     private var outlineNeeded = false
 
-    private var layoutDirection = LayoutDirection.Ltr
-
     private var tmpTouchPointPath: Path? = null
     private var tmpOpPath: Path? = null
-    private var calculatedOutline: Outline? = null
 
     /**
      * Updates the values of the outline. Returns `true` when the shape has changed.
      */
     fun update(
-        shape: Shape,
+        outline: Outline?,
         alpha: Float,
         clipToOutline: Boolean,
         elevation: Float,
-        layoutDirection: LayoutDirection,
-        density: Density
+        size: Size,
     ): Boolean {
         cachedOutline.alpha = alpha
-        val shapeChanged = this.shape != shape
-        if (shapeChanged) {
-            this.shape = shape
+        val outlineChanged = this.outline != outline
+        if (outlineChanged) {
+            this.outline = outline
             cacheIsDirty = true
         }
-        val outlineNeeded = clipToOutline || elevation > 0f
+        this.rectSize = size
+        val outlineNeeded = outline != null && (clipToOutline || elevation > 0f)
         if (this.outlineNeeded != outlineNeeded) {
             this.outlineNeeded = outlineNeeded
             cacheIsDirty = true
         }
-        if (this.layoutDirection != layoutDirection) {
-            this.layoutDirection = layoutDirection
-            cacheIsDirty = true
-        }
-        if (this.density != density) {
-            this.density = density
-            cacheIsDirty = true
-        }
-        return shapeChanged
+        return outlineChanged
     }
 
     /**
@@ -200,7 +179,7 @@
         if (!outlineNeeded) {
             return true
         }
-        val outline = calculatedOutline ?: return true
+        val outline = outline ?: return true
 
         return isInOutline(outline, position.x, position.y, tmpTouchPointPath, tmpOpPath)
     }
@@ -256,31 +235,20 @@
         }
     }
 
-    /**
-     * Updates the size.
-     */
-    fun update(size: Size) {
-        if (this.size != size) {
-            this.size = size
-            cacheIsDirty = true
-        }
-    }
-
     private fun updateCache() {
         if (cacheIsDirty) {
             rectTopLeft = Offset.Zero
-            rectSize = size
             roundedCornerRadius = 0f
             outlinePath = null
             cacheIsDirty = false
             usePathForClip = false
-            if (outlineNeeded && size.width > 0.0f && size.height > 0.0f) {
+            val outline = outline
+            if (outline != null && outlineNeeded &&
+                rectSize.width > 0.0f && rectSize.height > 0.0f) {
                 // Always assume the outline type is supported
                 // The methods to configure the outline will determine/update the flag
                 // if it not supported on the API level
                 isSupportedOutline = true
-                val outline = shape.createOutline(size, layoutDirection, density)
-                calculatedOutline = outline
                 when (outline) {
                     is Outline.Rectangle -> updateCacheWithRect(outline.rect)
                     is Outline.Rounded -> updateCacheWithRoundRect(outline.roundRect)
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
index b1c7718..2e6c0be 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/RenderNodeLayer.android.kt
@@ -19,10 +19,8 @@
 import android.os.Build
 import android.view.View
 import androidx.annotation.RequiresApi
-import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.geometry.MutableRect
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Canvas
 import androidx.compose.ui.graphics.CanvasHolder
 import androidx.compose.ui.graphics.Fields
@@ -36,10 +34,8 @@
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.layout.GraphicLayerInfo
 import androidx.compose.ui.node.OwnedLayer
-import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
 
 /**
  * RenderNode implementation of OwnedLayer.
@@ -63,11 +59,7 @@
                 ownerView.notifyLayerIsDirty(this, value)
             }
         }
-    private val outlineResolver = Snapshot.withoutReadObservation {
-        // we don't really care about observation here as density is applied manually
-        // not observing the density changes saves performance on recording reads
-        OutlineResolver(ownerView.density)
-    }
+    private val outlineResolver = OutlineResolver()
     private var isDestroyed = false
     private var drawnWithZ = false
 
@@ -117,11 +109,7 @@
 
     private var mutatedFields: Int = 0
 
-    override fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density,
-    ) {
+    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
         val maybeChangedFields = scope.mutatedFields or mutatedFields
         if (maybeChangedFields and Fields.TransformOrigin != 0) {
             this.transformOrigin = scope.transformOrigin
@@ -179,15 +167,14 @@
             renderNode.compositingStrategy = scope.compositingStrategy
         }
         val shapeChanged = outlineResolver.update(
-            scope.shape,
+            scope.outline,
             scope.alpha,
             clipToOutline,
             scope.shadowElevation,
-            layoutDirection,
-            density
+            scope.size,
         )
         if (outlineResolver.cacheIsDirty) {
-            renderNode.setOutline(outlineResolver.outline)
+            renderNode.setOutline(outlineResolver.androidOutline)
         }
         val isClippingManually = clipToOutline && !outlineResolver.outlineClipSupported
         if (wasClippingManually != isClippingManually || (isClippingManually && shapeChanged)) {
@@ -232,8 +219,7 @@
                 renderNode.top + height
             )
         ) {
-            outlineResolver.update(Size(width.toFloat(), height.toFloat()))
-            renderNode.setOutline(outlineResolver.outline)
+            renderNode.setOutline(outlineResolver.androidOutline)
             invalidate()
             matrixCache.invalidate()
         }
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
index 0ccc913..6e820de 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/ViewLayer.android.kt
@@ -21,10 +21,8 @@
 import android.view.View
 import android.view.ViewOutlineProvider
 import androidx.annotation.RequiresApi
-import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.ui.geometry.MutableRect
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Canvas
 import androidx.compose.ui.graphics.CanvasHolder
 import androidx.compose.ui.graphics.CompositingStrategy
@@ -39,10 +37,8 @@
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.layout.GraphicLayerInfo
 import androidx.compose.ui.node.OwnedLayer
-import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
 import java.lang.reflect.Field
 import java.lang.reflect.Method
 
@@ -58,11 +54,7 @@
     private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
     private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
 
-    private val outlineResolver = Snapshot.withoutReadObservation {
-        // we don't really care about observation here as density is applied manually
-        // not observing the density changes saves performance on recording reads
-        OutlineResolver(ownerView.density)
-    }
+    private val outlineResolver = OutlineResolver()
     // Value of the layerModifier's clipToBounds property
     private var clipToBounds = false
     private var clipBoundsCache: android.graphics.Rect? = null
@@ -132,11 +124,7 @@
 
     private var mutatedFields: Int = 0
 
-    override fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density,
-    ) {
+    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
         val maybeChangedFields = scope.mutatedFields or mutatedFields
         if (maybeChangedFields and Fields.TransformOrigin != 0) {
             this.mTransformOrigin = scope.transformOrigin
@@ -181,12 +169,11 @@
             this.clipToOutline = clipToOutline
         }
         val shapeChanged = outlineResolver.update(
-            scope.shape,
+            scope.outline,
             scope.alpha,
             clipToOutline,
             scope.shadowElevation,
-            layoutDirection,
-            density
+            scope.size
         )
         if (outlineResolver.cacheIsDirty) {
             updateOutlineResolver()
@@ -261,7 +248,7 @@
     }
 
     private fun updateOutlineResolver() {
-        this.outlineProvider = if (outlineResolver.outline != null) {
+        this.outlineProvider = if (outlineResolver.androidOutline != null) {
             OutlineProvider
         } else {
             null
@@ -287,7 +274,6 @@
         if (width != this.width || height != this.height) {
             pivotX = mTransformOrigin.pivotFractionX * width
             pivotY = mTransformOrigin.pivotFractionY * height
-            outlineResolver.update(Size(width.toFloat(), height.toFloat()))
             updateOutlineResolver()
             layout(left, top, left + width, top + height)
             resetClipBounds()
@@ -437,7 +423,7 @@
         val OutlineProvider = object : ViewOutlineProvider() {
             override fun getOutline(view: View, outline: android.graphics.Outline) {
                 view as ViewLayer
-                outline.set(view.outlineResolver.outline!!)
+                outline.set(view.outlineResolver.androidOutline!!)
             }
         }
         private var updateDisplayListIfDirtyMethod: Method? = null
diff --git a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
index bb76634..cd0eedd 100644
--- a/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
+++ b/compose/ui/ui/src/androidUnitTest/kotlin/androidx/compose/ui/node/LayoutNodeTest.kt
@@ -2688,11 +2688,7 @@
         val transform = Matrix()
         val inverseTransform = Matrix()
         return object : OwnedLayer {
-            override fun updateLayerProperties(
-                scope: ReusableGraphicsLayerScope,
-                layoutDirection: LayoutDirection,
-                density: Density
-            ) {
+            override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
                 transform.reset()
                 // This is not expected to be 100% accurate
                 transform.scale(scope.scaleX, scope.scaleY)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
index 9cf5728..ca8fd83 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/graphics/GraphicsLayerScope.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.ui.graphics
 
+import androidx.annotation.VisibleForTesting
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.ComposableOpenTarget
 import androidx.compose.runtime.RememberObserver
@@ -26,6 +27,7 @@
 import androidx.compose.ui.layout.PlacementScopeMarker
 import androidx.compose.ui.platform.LocalGraphicsContext
 import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
 
 /**
  * Default camera distance for all layers
@@ -411,6 +413,8 @@
 
     internal var graphicsDensity: Density = Density(1.0f)
 
+    internal var layoutDirection: LayoutDirection = LayoutDirection.Ltr
+
     override val density: Float
         get() = graphicsDensity.density
 
@@ -425,6 +429,10 @@
             }
         }
 
+    internal var outline: Outline? = null
+        @VisibleForTesting
+        internal set
+
     fun reset() {
         scaleX = 1f
         scaleY = 1f
@@ -444,7 +452,12 @@
         renderEffect = null
         compositingStrategy = CompositingStrategy.Auto
         size = Size.Unspecified
+        outline = null
         // mutatedFields should be reset last as all the setters above modify it.
         mutatedFields = 0
     }
+
+    internal fun updateOutline() {
+        outline = shape.createOutline(size, layoutDirection, graphicsDensity)
+    }
 }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
index 2e87f0b..8a6b586 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeCoordinator.kt
@@ -517,18 +517,16 @@
             }
             graphicsLayerScope.reset()
             graphicsLayerScope.graphicsDensity = layoutNode.density
+            graphicsLayerScope.layoutDirection = layoutNode.layoutDirection
             graphicsLayerScope.size = size.toSize()
             snapshotObserver.observeReads(this, onCommitAffectingLayerParams) {
                 layerBlock.invoke(graphicsLayerScope)
+                graphicsLayerScope.updateOutline()
             }
             val layerPositionalProperties = layerPositionalProperties
                 ?: LayerPositionalProperties().also { layerPositionalProperties = it }
             layerPositionalProperties.copyFrom(graphicsLayerScope)
-            layer.updateLayerProperties(
-                graphicsLayerScope,
-                layoutNode.layoutDirection,
-                layoutNode.density,
-            )
+            layer.updateLayerProperties(graphicsLayerScope)
             isClipping = graphicsLayerScope.clip
             lastLayerAlpha = graphicsLayerScope.alpha
             if (invokeOnLayoutChange) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
index f68b387..602a375 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/OwnedLayer.kt
@@ -22,10 +22,8 @@
 import androidx.compose.ui.graphics.Matrix
 import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
 import androidx.compose.ui.graphics.layer.GraphicsLayer
-import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
 
 /**
  * A layer returned by [Owner.createLayer] to separate drawn content.
@@ -33,13 +31,9 @@
 internal interface OwnedLayer {
 
     /**
-     * Applies the new layer properties and causing this layer to be redrawn.
+     * Applies the new layer properties, causing this layer to be redrawn.
      */
-    fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density,
-    )
+    fun updateLayerProperties(scope: ReusableGraphicsLayerScope)
 
     /**
      * Returns `false` if [position] is outside the clipped region or `true` if clipping
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
index 082fff8..adc95cf 100644
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkiaLayerTest.kt
@@ -18,6 +18,7 @@
 
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.CompositingStrategy
 import androidx.compose.ui.graphics.DefaultShadowColor
@@ -351,7 +352,8 @@
 
         layer.resize(IntSize(1, 2))
         layer.updateProperties(
-            clip = true
+            clip = true,
+            size = Size(1f, 2f)
         )
 
         assertFalse(layer.isInLayer(Offset(-1f, -1f)))
@@ -363,7 +365,8 @@
         layer.resize(IntSize(100, 200))
         layer.updateProperties(
             clip = true,
-            shape = CircleShape
+            shape = CircleShape,
+            size = Size(100f, 200f)
         )
 
         assertFalse(layer.isInLayer(Offset(5f, 5f)))
@@ -394,7 +397,8 @@
         shape: Shape = RectangleShape,
         clip: Boolean = false,
         renderEffect: RenderEffect? = null,
-        compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
+        compositingStrategy: CompositingStrategy = CompositingStrategy.Auto,
+        size: Size = Size.Zero
     ) {
         val scope = ReusableGraphicsLayerScope()
         scope.cameraDistance = cameraDistance
@@ -415,6 +419,9 @@
         scope.clip = clip
         scope.renderEffect = renderEffect
         scope.compositingStrategy = compositingStrategy
-        updateLayerProperties(scope, LayoutDirection.Ltr, Density(1f))
+        scope.layoutDirection = LayoutDirection.Ltr
+        scope.graphicsDensity = Density(1f)
+        scope.outline = shape.createOutline(size, scope.layoutDirection, scope.graphicsDensity)
+        updateLayerProperties(scope)
     }
 }
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt
deleted file mode 100644
index 580fd2b..0000000
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/OutlineCache.skiko.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.ui.platform
-
-import androidx.compose.ui.graphics.Outline
-import androidx.compose.ui.graphics.Shape
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.unit.toSize
-
-/**
- * Class for storing outline. Recalculates outline when [size] or [shape] is changed.
- * It' s needed so we don't have to recreate it every time we use it for rendering
- * (it can be expensive to create outline every frame).
- */
-internal class OutlineCache(
-    density: Density,
-    size: IntSize,
-    shape: Shape,
-    layoutDirection: LayoutDirection
-) {
-    var density = density
-        set(value) {
-            if (value != field) {
-                field = value
-                outline = createOutline()
-            }
-        }
-
-    var size = size
-        set(value) {
-            if (value != field) {
-                field = value
-                outline = createOutline()
-            }
-        }
-
-    var shape = shape
-        set(value) {
-            if (value != field) {
-                field = value
-                outline = createOutline()
-            }
-        }
-
-    var layoutDirection = layoutDirection
-        set(value) {
-            if (value != field) {
-                field = value
-                outline = createOutline()
-            }
-        }
-
-    var outline: Outline = createOutline()
-        private set
-
-    private fun createOutline() =
-        shape.createOutline(size.toSize(), layoutDirection, density)
-}
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
index 41bffb8..b39d3ea 100644
--- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/SkiaLayer.skiko.kt
@@ -31,7 +31,6 @@
 import androidx.compose.ui.graphics.Outline
 import androidx.compose.ui.graphics.Paint
 import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.RenderEffect
 import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
 import androidx.compose.ui.graphics.SkiaBackedCanvas
@@ -47,7 +46,6 @@
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.toSize
 import org.jetbrains.skia.ClipMode
@@ -64,8 +62,7 @@
 ) : OwnedLayer {
     private var size = IntSize.Zero
     private var position = IntOffset.Zero
-    private var outlineCache =
-        OutlineCache(density, size, RectangleShape, LayoutDirection.Ltr)
+    private var outline: Outline? = null
     // Internal for testing
     internal val matrix = Matrix()
     private val pictureRecorder = PictureRecorder()
@@ -105,7 +102,6 @@
     override fun resize(size: IntSize) {
         if (size != this.size) {
             this.size = size
-            outlineCache.size = size
             updateMatrix()
             invalidate()
         }
@@ -133,11 +129,10 @@
 
         val x = position.x
         val y = position.y
-        if (outlineCache.shape === RectangleShape) {
-            return 0f <= x && x < size.width && 0f <= y && y < size.height
-        }
 
-        return isInOutline(outlineCache.outline, x, y)
+        val outline = outline ?: return true
+
+        return isInOutline(outline, x, y)
     }
 
     private fun getMatrix(inverse: Boolean): Matrix {
@@ -152,11 +147,7 @@
     }
     private var mutatedFields: Int = 0
 
-    override fun updateLayerProperties(
-        scope: ReusableGraphicsLayerScope,
-        layoutDirection: LayoutDirection,
-        density: Density,
-    ) {
+    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
         val maybeChangedFields = scope.mutatedFields or mutatedFields
         this.transformOrigin = scope.transformOrigin
         this.translationX = scope.translationX
@@ -169,14 +160,12 @@
         this.alpha = scope.alpha
         this.clip = scope.clip
         this.shadowElevation = scope.shadowElevation
-        this.density = density
+        this.density = scope.graphicsDensity
         this.renderEffect = scope.renderEffect
         this.ambientShadowColor = scope.ambientShadowColor
         this.spotShadowColor = scope.spotShadowColor
         this.compositingStrategy = scope.compositingStrategy
-        outlineCache.shape = scope.shape
-        outlineCache.layoutDirection = layoutDirection
-        outlineCache.density = density
+        this.outline = scope.outline
         if (maybeChangedFields and Fields.MatrixAffectingFields != 0) {
             updateMatrix()
         }
@@ -245,13 +234,17 @@
                 drawShadow(canvas)
             }
 
-            if (clip) {
+            val outline = outline
+            val isClipping = if (clip && outline != null) {
                 canvas.save()
-                when (val outline = outlineCache.outline) {
+                when (outline) {
                     is Outline.Rectangle -> canvas.clipRect(outline.rect)
                     is Outline.Rounded -> canvas.clipRoundRect(outline.roundRect)
                     is Outline.Generic -> canvas.clipPath(outline.path)
                 }
+                true
+            } else {
+                false
             }
 
             val currentRenderEffect = renderEffect
@@ -279,7 +272,7 @@
 
             drawBlock(canvas, null)
             canvas.restore()
-            if (clip) {
+            if (isClipping) {
                 canvas.restore()
             }
         }
@@ -299,7 +292,7 @@
     override fun updateDisplayList() = Unit
 
     fun drawShadow(canvas: Canvas) = with(density) {
-        val path = when (val outline = outlineCache.outline) {
+        val path = when (val outline = outline) {
             is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
             is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
             is Outline.Generic -> outline.path
diff --git a/docs/api_guidelines/dependencies.md b/docs/api_guidelines/dependencies.md
index 770ad70..74b88cd 100644
--- a/docs/api_guidelines/dependencies.md
+++ b/docs/api_guidelines/dependencies.md
@@ -299,6 +299,11 @@
 
 #### Protobuf {#dependencies-protobuf}
 
+**Note**: It is preferred to use the [`wire`](https://github.com/square/wire)
+library for handling protocol buffers in Android libraries as it has a binary
+stable runtime. An example of its usage can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:benchmark/benchmark-common/build.gradle?q=wireRuntime%20file:gradle&ss=androidx%2Fplatform%2Fframeworks%2Fsupport).
+
 [Protocol buffers](https://developers.google.com/protocol-buffers) provide a
 language- and platform-neutral mechanism for serializing structured data. The
 implementation enables developers to maintain protocol compatibility across
@@ -306,8 +311,18 @@
 library versions included in their APKs.
 
 The Protobuf library itself, however, does not guarantee ABI compatibility
-across minor versions and a specific version **must** be bundled with a library
-to avoid conflict with other dependencies used by the developer.
+across minor versions and a specific version **must** be used with a library to
+avoid conflict with other dependencies used by the developer. To do this, you
+must first create a new project to repackage the protobuf runtime classes, and
+then have it as a dependency in the project you generate protos in. In the
+project that generates protos, you must also relocate any import statements
+containing `com.google.protobuf` to your target package name. The
+[AndroidXRepackagePlugin](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:buildSrc/private/src/main/kotlin/androidx/build/AndroidXRepackageImplPlugin.kt)
+abstracts this for you. An example of its use to repackage the protobuf runtime
+library can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/protolayout/protolayout-external-protobuf/build.gradle)
+and its associated use in the library that generates protos can be found
+[here](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:wear/protolayout/protolayout-proto/build.gradle).
 
 Additionally, the Java API surface generated by the Protobuf compiler is not
 guaranteed to be stable and **must not** be exposed to developers. Library
diff --git a/graphics/graphics-path/build.gradle b/graphics/graphics-path/build.gradle
index 753e958..f24d85e 100644
--- a/graphics/graphics-path/build.gradle
+++ b/graphics/graphics-path/build.gradle
@@ -55,7 +55,6 @@
                     "-std=c++17",
                     "-Wno-unused-command-line-argument",
                     "-Wl,--hash-style=both", // Required to support API levels below 23
-                    "-fno-stack-protector",
                     "-fno-exceptions",
                     "-fno-unwind-tables",
                     "-fno-asynchronous-unwind-tables",
@@ -67,7 +66,6 @@
                     "-fomit-frame-pointer",
                     "-ffunction-sections",
                     "-fdata-sections",
-                    "-fstack-protector",
                     "-Wl,--gc-sections",
                     "-Wl,-Bsymbolic-functions",
                     "-nostdlib++"
diff --git a/libraryversions.toml b/libraryversions.toml
index 138ac26..27af2cd 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -171,7 +171,7 @@
 WEBKIT = "1.12.0-alpha01"
 # Adding a comment to prevent merge conflicts for Window artifact
 WINDOW = "1.3.0-beta02"
-WINDOW_EXTENSIONS = "1.3.0-beta01"
+WINDOW_EXTENSIONS = "1.3.0-rc01"
 WINDOW_EXTENSIONS_CORE = "1.1.0-alpha01"
 WINDOW_SIDECAR = "1.0.0-rc01"
 WORK = "2.10.0-alpha02"
diff --git a/playground-common/playground-plugin/build.gradle b/playground-common/playground-plugin/build.gradle
index cc6735b..c5b8bb8 100644
--- a/playground-common/playground-plugin/build.gradle
+++ b/playground-common/playground-plugin/build.gradle
@@ -21,8 +21,8 @@
 
 dependencies {
     implementation(project(":shared"))
-    implementation("com.gradle:gradle-enterprise-gradle-plugin:3.16")
-    implementation("com.gradle:common-custom-user-data-gradle-plugin:1.12")
+    implementation("com.gradle:develocity-gradle-plugin:3.17.2")
+    implementation("com.gradle:common-custom-user-data-gradle-plugin:2.0.1")
     implementation("supportBuildSrc:private")
     implementation("supportBuildSrc:public")
     implementation("supportBuildSrc:plugins")
@@ -37,8 +37,8 @@
             implementationClass = "androidx.playground.PlaygroundPlugin"
         }
         gradleEnterpriseConventions {
-            id = "playground-ge-conventions"
-            implementationClass = "androidx.playground.GradleEnterpriseConventionsPlugin"
+            id = "playground-develocity-conventions"
+            implementationClass = "androidx.playground.GradleDevelocityConventionsPlugin"
         }
     }
 }
diff --git a/playground-common/playground-plugin/settings.gradle b/playground-common/playground-plugin/settings.gradle
index 1c6802c..9680ada 100644
--- a/playground-common/playground-plugin/settings.gradle
+++ b/playground-common/playground-plugin/settings.gradle
@@ -29,7 +29,7 @@
         mavenCentral()
         google()
         gradlePluginPortal().content {
-            it.includeModule("com.gradle", "gradle-enterprise-gradle-plugin")
+            it.includeModule("com.gradle", "develocity-gradle-plugin")
             it.includeModule("com.gradle", "common-custom-user-data-gradle-plugin")
             it.includeModule("org.spdx", "spdx-gradle-plugin")
             it.includeModule("com.github.johnrengelman.shadow",
diff --git a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
similarity index 82%
rename from playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt
rename to playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
index aa5351f..08b00ca 100644
--- a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleEnterpriseConventionsPlugin.kt
+++ b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/GradleDevelocityConventionsPlugin.kt
@@ -16,34 +16,32 @@
 
 package androidx.playground
 
-import com.gradle.enterprise.gradleplugin.internal.extension.BuildScanExtensionWithHiddenFeatures
-import org.gradle.api.Plugin
-import org.gradle.api.initialization.Settings
-import org.gradle.caching.http.HttpBuildCache
-import org.gradle.kotlin.dsl.gradleEnterprise
 import java.net.InetAddress
 import java.net.URI
 import java.util.function.Function
+import org.gradle.api.Plugin
+import org.gradle.api.initialization.Settings
+import org.gradle.caching.http.HttpBuildCache
+import org.gradle.kotlin.dsl.develocity
 
-class GradleEnterpriseConventionsPlugin : Plugin<Settings> {
+class GradleDevelocityConventionsPlugin : Plugin<Settings> {
     override fun apply(settings: Settings) {
-        settings.apply(mapOf("plugin" to "com.gradle.enterprise"))
+        settings.apply(mapOf("plugin" to "com.gradle.develocity"))
         settings.apply(mapOf("plugin" to "com.gradle.common-custom-user-data-gradle-plugin"))
 
         // Github Actions always sets a "CI" environment variable
         val isCI = System.getenv("CI") != null
 
-        settings.gradleEnterprise {
-            server = "https://ge.androidx.dev"
-
+        settings.develocity {
+            server.set("https://ge.androidx.dev")
             buildScan.apply {
-                publishAlways()
-                (this as BuildScanExtensionWithHiddenFeatures).publishIfAuthenticated()
-                isUploadInBackground = !isCI
-                capture.isTaskInputFiles = true
-
+                uploadInBackground.set(!isCI)
+                capture.fileFingerprints.set(true)
                 obfuscation.hostname(HostnameHider())
                 obfuscation.ipAddresses(IpAddressHider())
+                publishing.onlyIf {
+                    it.isAuthenticated
+                }
             }
         }
 
@@ -88,4 +86,4 @@
             return listOf("0.0.0.0")
         }
     }
-}
\ No newline at end of file
+}
diff --git a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
index b6960f9..22c1f44 100644
--- a/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
+++ b/playground-common/playground-plugin/src/main/kotlin/androidx/playground/PlaygroundPlugin.kt
@@ -21,7 +21,7 @@
 
 class PlaygroundPlugin : Plugin<Settings> {
     override fun apply(settings: Settings) {
-        settings.apply(mapOf("plugin" to "playground-ge-conventions"))
+        settings.apply(mapOf("plugin" to "playground-develocity-conventions"))
         settings.extensions.create("playground", PlaygroundExtension::class.java, settings)
         validateJvm(settings)
     }
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
index 2513b5d..f253f94 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/BaseFragment.kt
@@ -16,11 +16,20 @@
 
 package androidx.privacysandbox.ui.integration.testapp
 
+import android.app.Activity
 import android.os.Bundle
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.TextView
+import android.widget.Toast
 import androidx.fragment.app.Fragment
 import androidx.privacysandbox.sdkruntime.client.SdkSandboxManagerCompat
+import androidx.privacysandbox.sdkruntime.client.SdkSandboxProcessDeathCallbackCompat
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState
+import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionStateChangedListener
 import androidx.privacysandbox.ui.client.view.SandboxedSdkView
 import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
+import kotlinx.coroutines.runBlocking
 
 /**
  * Base fragment to be used for testing different manual flows.
@@ -31,16 +40,22 @@
  */
 abstract class BaseFragment : Fragment() {
     private lateinit var sdkApi: ISdkApi
+    private lateinit var sdkSandboxManager: SdkSandboxManagerCompat
+    private lateinit var activity: Activity
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
-        val sdkSandboxManager = SdkSandboxManagerCompat.from(requireContext())
-        val loadedSdks = sdkSandboxManager.getSandboxedSdks()
-        val loadedSdk = loadedSdks.firstOrNull { it.getSdkInfo()?.name == SDK_NAME }
-        if (loadedSdk == null) {
-            throw IllegalStateException("SDK not loaded")
+        activity = requireActivity()
+        sdkSandboxManager = SdkSandboxManagerCompat.from(requireContext())
+        runBlocking {
+            val loadedSdks = sdkSandboxManager.getSandboxedSdks()
+            var loadedSdk = loadedSdks.firstOrNull { it.getSdkInfo()?.name == SDK_NAME }
+            if (loadedSdk == null) {
+                loadedSdk = sdkSandboxManager.loadSdk(SDK_NAME, Bundle())
+                sdkSandboxManager.loadSdk(MEDIATEE_SDK_NAME, Bundle())
+            }
+            sdkApi = ISdkApi.Stub.asInterface(loadedSdk.getInterface())
         }
-        sdkApi = ISdkApi.Stub.asInterface(loadedSdk.getInterface())
     }
 
     /**
@@ -50,6 +65,20 @@
         return sdkApi
     }
 
+    fun SandboxedSdkView.addStateChangedListener() {
+        addStateChangedListener(StateChangeListener(this))
+    }
+
+    /**
+     * Unloads all SDKs, resulting in sandbox death. This method registers a death callback to
+     * ensure that the app is not also killed.
+     */
+    fun unloadAllSdks() {
+        sdkSandboxManager.addSdkSandboxProcessDeathCallback(Runnable::run, DeathCallbackImpl())
+        sdkSandboxManager.unloadSdk(SDK_NAME)
+        sdkSandboxManager.unloadSdk(MEDIATEE_SDK_NAME)
+    }
+
     /**
      * Called when the app's drawer layout state changes. When called, change the Z-order of
      * any [SandboxedSdkView] owned by the fragment to ensure that the remote UI is not drawn over
@@ -58,8 +87,37 @@
      */
     abstract fun handleDrawerStateChange(isDrawerOpen: Boolean)
 
+    private inner class StateChangeListener(val view: SandboxedSdkView) :
+        SandboxedSdkUiSessionStateChangedListener {
+        override fun onStateChanged(state: SandboxedSdkUiSessionState) {
+            Log.i(TAG, "UI session state changed to: $state")
+            if (state is SandboxedSdkUiSessionState.Error) {
+                // If the session fails to open, display the error.
+                val parent = view.parent as ViewGroup
+                val index = parent.indexOfChild(view)
+                val textView = TextView(requireActivity())
+                textView.text = state.throwable.message
+
+                requireActivity().runOnUiThread {
+                    parent.removeView(view)
+                    parent.addView(textView, index)
+                }
+            }
+        }
+    }
+
+    private inner class DeathCallbackImpl : SdkSandboxProcessDeathCallbackCompat {
+        override fun onSdkSandboxDied() {
+            activity.runOnUiThread {
+                Toast.makeText(activity, "Sandbox died", Toast.LENGTH_LONG).show()
+            }
+        }
+    }
+
     companion object {
         private const val SDK_NAME = "androidx.privacysandbox.ui.integration.testsdkprovider"
+        private const val MEDIATEE_SDK_NAME =
+            "androidx.privacysandbox.ui.integration.mediateesdkprovider"
         const val TAG = "TestSandboxClient"
     }
 }
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt
deleted file mode 100644
index 3a27fde..0000000
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/EmptyFragment.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.privacysandbox.ui.integration.testapp
-
-class EmptyFragment : BaseFragment() {
-    override fun handleDrawerStateChange(isDrawerOpen: Boolean) {
-    }
-}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
index ae13cb1..8d7af17 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainActivity.kt
@@ -109,7 +109,7 @@
             val itemId = it.itemId
             when (itemId) {
                 R.id.item_main -> switchContentFragment(MainFragment(), it.title)
-                R.id.item_empty -> switchContentFragment(EmptyFragment(), it.title)
+                R.id.item_sandbox_death -> switchContentFragment(SandboxDeathFragment(), it.title)
                 else -> {
                     Log.e(TAG, "Invalid fragment option")
                     true
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt
index 7944323..e9b2ccc 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/MainFragment.kt
@@ -17,16 +17,12 @@
 package androidx.privacysandbox.ui.integration.testapp
 
 import android.os.Bundle
-import android.util.Log
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.Button
 import android.widget.LinearLayout
-import android.widget.TextView
 import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
-import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionState
-import androidx.privacysandbox.ui.client.view.SandboxedSdkUiSessionStateChangedListener
 import androidx.privacysandbox.ui.client.view.SandboxedSdkView
 import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
 import com.google.android.material.switchmaterial.SwitchMaterial
@@ -79,7 +75,7 @@
     }
 
     private fun loadWebViewBannerAd() {
-        webViewBannerView.addStateChangedListener(StateChangeListener(webViewBannerView))
+        webViewBannerView.addStateChangedListener()
         webViewBannerView.setAdapter(
             SandboxedUiAdapterFactory.createFromCoreLibInfo(
             sdkApi.loadLocalWebViewAd()
@@ -101,7 +97,7 @@
     }
 
     private fun loadBottomBannerAd() {
-        bottomBannerView.addStateChangedListener(StateChangeListener(bottomBannerView))
+        bottomBannerView.addStateChangedListener()
         bottomBannerView.layoutParams = inflatedView.findViewById<LinearLayout>(
             R.id.bottom_banner_container).layoutParams
         requireActivity().runOnUiThread {
@@ -115,8 +111,7 @@
     }
 
     private fun loadResizableBannerAd() {
-        resizableBannerView.addStateChangedListener(
-            StateChangeListener(resizableBannerView))
+        resizableBannerView.addStateChangedListener()
         resizableBannerView.setAdapter(
             SandboxedUiAdapterFactory.createFromCoreLibInfo(
             sdkApi.loadTestAdWithWaitInsideOnDraw(/*text=*/ "Resizable View")
@@ -164,23 +159,4 @@
             sdkApi.requestResize(newWidth, newHeight)
         }
     }
-
-    private inner class StateChangeListener(val view: SandboxedSdkView) :
-        SandboxedSdkUiSessionStateChangedListener {
-        override fun onStateChanged(state: SandboxedSdkUiSessionState) {
-            Log.i(TAG, "UI session state changed to: $state")
-            if (state is SandboxedSdkUiSessionState.Error) {
-                // If the session fails to open, display the error.
-                val parent = view.parent as ViewGroup
-                val index = parent.indexOfChild(view)
-                val textView = TextView(requireActivity())
-                textView.text = state.throwable.message
-
-                requireActivity().runOnUiThread {
-                    parent.removeView(view)
-                    parent.addView(textView, index)
-                }
-            }
-        }
-    }
 }
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt
new file mode 100644
index 0000000..2c85ccd
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/java/androidx/privacysandbox/ui/integration/testapp/SandboxDeathFragment.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.privacysandbox.ui.integration.testapp
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
+import androidx.privacysandbox.ui.client.view.SandboxedSdkView
+import androidx.privacysandbox.ui.integration.testaidl.ISdkApi
+
+class SandboxDeathFragment : BaseFragment() {
+    private lateinit var sdkApi: ISdkApi
+    private lateinit var inflatedView: View
+    private lateinit var sandboxedSdkView: SandboxedSdkView
+
+    override fun handleDrawerStateChange(isDrawerOpen: Boolean) {
+        sandboxedSdkView.orderProviderUiAboveClientUi(!isDrawerOpen)
+    }
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        inflatedView = inflater.inflate(R.layout.fragment_sandbox_death, container, false)
+        sdkApi = getSdkApi()
+        onLoaded()
+        return inflatedView
+    }
+
+    private fun onLoaded() {
+        sandboxedSdkView = inflatedView.findViewById(R.id.remote_view)
+        sandboxedSdkView.addStateChangedListener()
+        sandboxedSdkView.setAdapter(
+            SandboxedUiAdapterFactory.createFromCoreLibInfo(sdkApi.loadTestAd("Test Ad")))
+        val unloadSdksButton: Button = inflatedView.findViewById(R.id.unload_all_sdks_button)
+        unloadSdksButton.setOnClickListener {
+            unloadAllSdks()
+        }
+    }
+}
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
index 836375e..1cde836 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/action_menu.xml
@@ -19,6 +19,6 @@
         android:id="@+id/item_main"
         android:title="Main CUJ" />
     <item
-        android:id="@+id/item_empty"
-        android:title="Empty CUJ" />
+        android:id="@+id/item_sandbox_death"
+        android:title="Sandbox Death CUJ" />
 </menu>
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml
new file mode 100644
index 0000000..02015bb
--- /dev/null
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/layout/fragment_sandbox_death.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2024 The Android Open Source Project
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  -->
+
+<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+    <androidx.privacysandbox.ui.client.view.SandboxedSdkView
+        android:layout_width="500dp"
+        android:layout_height="500dp"
+        android:id="@+id/remote_view"/>
+    <Button
+        android:layout_width="match_parent"
+        android:layout_height="100dp"
+        android:id="@+id/unload_all_sdks_button"
+        android:text="@string/unload_sdks_button"/>
+</androidx.appcompat.widget.LinearLayoutCompat>
\ No newline at end of file
diff --git a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
index 5ecbc59..33f5242 100644
--- a/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
+++ b/privacysandbox/ui/integration-tests/testapp/src/main/res/values/strings.xml
@@ -21,4 +21,5 @@
     <string name="local_to_internet_switch">local webview</string>
     <string name="mediation_switch">Mediation</string>
     <string name="app_owned_mediatee_switch">AppOwnedMediatee</string>
+    <string name="unload_sdks_button">Unload SDKs</string>
 </resources>
\ No newline at end of file
diff --git a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
index a94fd8f..d25dc59 100644
--- a/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
+++ b/privacysandbox/ui/ui-client/src/main/java/androidx/privacysandbox/ui/client/SandboxedUiAdapterFactory.kt
@@ -23,6 +23,7 @@
 import android.os.Build
 import android.os.Bundle
 import android.os.IBinder
+import android.os.RemoteException
 import android.util.Log
 import android.view.Display
 import android.view.SurfaceControlViewHost
@@ -233,14 +234,16 @@
                 context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
             val displayId = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY).displayId
 
-            adapterInterface.openRemoteSession(
-                windowInputToken,
-                displayId,
-                initialWidth,
-                initialHeight,
-                isZOrderOnTop,
-                RemoteSessionClient(context, client, clientExecutor)
-            )
+            tryToCallRemoteObject {
+                adapterInterface.openRemoteSession(
+                    windowInputToken,
+                    displayId,
+                    initialWidth,
+                    initialHeight,
+                    isZOrderOnTop,
+                    RemoteSessionClient(context, client, clientExecutor)
+                )
+            }
         }
 
         class RemoteSessionClient(
@@ -263,6 +266,11 @@
                         .onSessionOpened(SessionImpl(surfaceView,
                             remoteSessionController, surfacePackage))
                 }
+                tryToCallRemoteObject {
+                    remoteSessionController.asBinder().linkToDeath({
+                        onRemoteSessionError("Remote process died")
+                    }, 0)
+                }
             }
 
             override fun onRemoteSessionError(errorString: String) {
@@ -287,7 +295,9 @@
             override val view: View = surfaceView
 
             override fun notifyConfigurationChanged(configuration: Configuration) {
-                remoteSessionController.notifyConfigurationChanged(configuration)
+                tryToCallRemoteObject {
+                    remoteSessionController.notifyConfigurationChanged(configuration)
+                }
             }
 
             @SuppressLint("ClassVerificationFailure")
@@ -302,7 +312,9 @@
                 }
 
                 val providerResizeRunnable = Runnable {
-                    remoteSessionController.notifyResized(width, height)
+                    tryToCallRemoteObject {
+                        remoteSessionController.notifyResized(width, height)
+                    }
                 }
 
                 val syncGroup = SurfaceSyncGroup("AppAndSdkViewsSurfaceSync")
@@ -314,11 +326,27 @@
 
             override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
                 surfaceView.setZOrderOnTop(isZOrderOnTop)
-                remoteSessionController.notifyZOrderChanged(isZOrderOnTop)
+                tryToCallRemoteObject {
+                    remoteSessionController.notifyZOrderChanged(isZOrderOnTop)
+                }
             }
 
             override fun close() {
-                remoteSessionController.close()
+                 tryToCallRemoteObject { remoteSessionController.close() }
+            }
+        }
+
+        private companion object {
+
+            /**
+             * Tries to call the remote object and handles exceptions if the remote object has died.
+             */
+            private inline fun tryToCallRemoteObject(function: () -> Unit) {
+                try {
+                    function()
+                } catch (e: RemoteException) {
+                    Log.e(TAG, "Calling remote object failed: $e")
+                }
             }
         }
     }
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
index 0419088..79fe546 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/AutoClosingRoomOpenHelperTest.java
@@ -43,6 +43,7 @@
 import org.jetbrains.annotations.NotNull;
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 
@@ -253,6 +254,8 @@
         assertFalse(testDatabase.isOpen());
     }
 
+    // TODO(336671494): broken test
+    @Ignore
     @Test
     @MediumTest
     public void invalidationObserver_isCalledOnEachInvalidation()
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
index bf9a575..43e65e1 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultiInstanceInvalidationTest.java
@@ -208,6 +208,8 @@
         assertThat(db1.getCustomerDao().countCustomers(), is(1));
     }
 
+    // TODO(335890993): broken test
+    @Ignore
     @Test
     public void invalidationInAnotherInstance_closed() throws Exception {
         final SampleDatabase db1 = openDatabase(true);
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
index da629f7..46e753d 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XAnnotationValueTest.kt
@@ -18,6 +18,7 @@
 
 import androidx.kruth.assertThat
 import androidx.room.compiler.codegen.JArrayTypeName
+import androidx.room.compiler.processing.compat.XConverters.toKS
 import androidx.room.compiler.processing.util.Source
 import androidx.room.compiler.processing.util.XTestInvocation
 import androidx.room.compiler.processing.util.asJClassName
@@ -1284,7 +1285,10 @@
             val kClassKTypeName = kotlin.reflect.KClass::class.asKClassName().parameterizedBy(STAR)
             fun checkSingleValue(annotationValue: XAnnotationValue, expectedValue: String) {
                 // TODO(bcorso): Consider making the value types match in this case.
-                if (!invocation.isKsp || (sourceKind == SourceKind.JAVA && !isPreCompiled)) {
+                if (!invocation.isKsp ||
+                        (invocation.processingEnv.toKS().kspVersion < KotlinVersion(2, 0) &&
+                        sourceKind == SourceKind.JAVA &&
+                        !isPreCompiled)) {
                     assertThat(annotationValue.valueType.asTypeName().java)
                         .isEqualTo(classJTypeName)
                 } else {
@@ -1299,7 +1303,10 @@
 
             fun checkListValues(annotationValue: XAnnotationValue, vararg expectedValues: String) {
                 // TODO(bcorso): Consider making the value types match in this case.
-                if (!invocation.isKsp || (sourceKind == SourceKind.JAVA && !isPreCompiled)) {
+                if (!invocation.isKsp ||
+                        (invocation.processingEnv.toKS().kspVersion < KotlinVersion(2, 0) &&
+                        sourceKind == SourceKind.JAVA &&
+                        !isPreCompiled)) {
                     assertThat(annotationValue.valueType.asTypeName().java)
                         .isEqualTo(JArrayTypeName.of(classJTypeName))
                 } else {
diff --git a/settings.gradle b/settings.gradle
index 8473061..51022df 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -28,8 +28,8 @@
         classpath("com.google.protobuf:protobuf-java:3.22.3")
         // upgrade okio for gcpbuildcache that is compatible with the wire plugin used by androidx
         classpath("com.squareup.okio:okio:3.3.0")
-        classpath("com.gradle:gradle-enterprise-gradle-plugin:3.16")
-        classpath("com.gradle:common-custom-user-data-gradle-plugin:1.12")
+        classpath("com.gradle:develocity-gradle-plugin:3.17.2")
+        classpath("com.gradle:common-custom-user-data-gradle-plugin:2.0.1")
         classpath("androidx.build.gradle.gcpbuildcache:gcpbuildcache:1.0.0-beta07")
     }
 }
@@ -72,17 +72,17 @@
     )
 }
 
-apply(plugin: "com.gradle.enterprise")
+apply(plugin: "com.gradle.develocity")
 apply(plugin: "com.gradle.common-custom-user-data-gradle-plugin")
 apply(plugin: "androidx.build.gradle.gcpbuildcache")
 
 def BUILD_NUMBER = System.getenv("BUILD_NUMBER")
-gradleEnterprise {
+develocity {
     server = "https://ge.androidx.dev"
 
     buildScan {
         capture {
-            taskInputFiles = true
+            fileFingerprints.set(true)
         }
         obfuscation {
             hostname { host -> "unset" }
@@ -95,9 +95,7 @@
         value("androidx.projects", getRequestedProjectSubsetName() ?: "Unset")
         value("androidx.useMaxDepVersions", providers.gradleProperty("androidx.useMaxDepVersions").isPresent().toString())
 
-	// Publish scan for androidx-main
-	publishAlways()
-	publishIfAuthenticated()
+        publishing.onlyIf { it.authenticated }
     }
 }
 
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
index 6bb6e21..6ece9c7 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/SwipeToRevealDemo.kt
@@ -42,6 +42,7 @@
 import androidx.wear.compose.foundation.ExpandableState
 import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
 import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealState
 import androidx.wear.compose.foundation.RevealValue
 import androidx.wear.compose.foundation.SwipeToDismissBoxState
 import androidx.wear.compose.foundation.edgeSwipeToDismiss
@@ -67,7 +68,7 @@
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 
-@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class)
+@OptIn(ExperimentalWearFoundationApi::class)
 @Composable
 fun SwipeToRevealChips(swipeToDismissBoxState: SwipeToDismissBoxState) {
     val expandableStateMapping = rememberExpandableStateMapping<Int>(
@@ -90,7 +91,7 @@
                 var undoActionEnabled by remember { mutableStateOf(true) }
                 val revealState = rememberRevealState()
                 val coroutineScope = rememberCoroutineScope()
-                val deleteItem = {
+                val deleteItem: () -> Unit = {
                     coroutineScope.launch {
                         revealState.animateTo(RevealValue.Revealed)
 
@@ -103,7 +104,7 @@
                         }
                     }
                 }
-                val addItem = {
+                val addItem: () -> Unit = {
                     coroutineScope.launch {
                         revealState.animateTo(RevealValue.Revealed)
                         itemCount++
@@ -116,85 +117,33 @@
                         }
                     }
                 }
-                if (expanded) {
-                    SwipeToRevealChip(
-                        modifier = Modifier
-                            .edgeSwipeToDismiss(swipeToDismissBoxState)
-                            .semantics {
-                                customActions = listOf(
-                                    CustomAccessibilityAction("Delete") {
-                                        deleteItem()
-                                        true
-                                    },
-                                    CustomAccessibilityAction("Duplicate") {
-                                        addItem()
-                                        true
-                                    }
-                                )
-                            },
-                        revealState = revealState,
-                        onFullSwipe = { deleteItem() },
-                        primaryAction = {
-                            SwipeToRevealPrimaryAction(
-                                revealState = revealState,
-                                icon = {
-                                    Icon(
-                                        SwipeToRevealDefaults.Delete,
-                                        contentDescription = "Delete"
-                                    )
-                                },
-                                label = { Text(text = "Delete") },
-                                onClick = { deleteItem() },
-                            )
-                        },
-                        secondaryAction = {
-                            SwipeToRevealSecondaryAction(
-                                revealState = revealState,
-                                content = {
-                                    Icon(Icons.Outlined.Add, contentDescription = "Duplicate")
-                                },
-                                onClick = { addItem() }
-                            )
-                        },
-                        undoPrimaryAction = {
-                            SwipeToRevealUndoAction(
-                                revealState = revealState,
-                                label = { Text("Undo Primary Action") },
-                                onClick = {
-                                    if (undoActionEnabled) {
-                                        coroutineScope.launch {
-                                            // reset the state when undo is clicked
-                                            revealState.animateTo(RevealValue.Covered)
-                                            revealState.lastActionType = RevealActionType.None
-                                        }
-                                    }
-                                }
-                            )
-                        },
-                        undoSecondaryAction = {
-                            SwipeToRevealUndoAction(
-                                revealState = revealState,
-                                label = { Text("Undo Secondary Action") },
-                                onClick = {
-                                    coroutineScope.launch {
-                                        itemCount--
-                                        // reset the state when undo is clicked
-                                        revealState.animateTo(RevealValue.Covered)
-                                        revealState.lastActionType = RevealActionType.None
-                                    }
-                                }
-                            )
+                val undoDeleteItem: () -> Unit = {
+                    if (undoActionEnabled) {
+                        coroutineScope.launch {
+                            // reset the state when undo is clicked
+                            revealState.animateTo(RevealValue.Covered)
+                            revealState.lastActionType = RevealActionType.None
                         }
-                    ) {
-                        Chip(
-                            onClick = { /*TODO*/ },
-                            colors = ChipDefaults.secondaryChipColors(),
-                            modifier = Modifier.fillMaxWidth(),
-                            label = {
-                                Text("Chip #$it")
-                            }
-                        )
                     }
+                }
+                val undoAddItem: () -> Unit = {
+                    coroutineScope.launch {
+                        itemCount--
+                        // reset the state when undo is clicked
+                        revealState.animateTo(RevealValue.Covered)
+                        revealState.lastActionType = RevealActionType.None
+                    }
+                }
+                if (expanded) {
+                    SwipeToRevealChipExpandable(
+                        modifier = Modifier.edgeSwipeToDismiss(swipeToDismissBoxState),
+                        text = "Chip #$it",
+                        revealState = revealState,
+                        onDeleteAction = deleteItem,
+                        onUndoDelete = undoDeleteItem,
+                        onDuplicateAction = addItem,
+                        onUndoDuplicate = undoAddItem
+                    )
                 } else {
                     Spacer(modifier = Modifier.width(200.dp))
                 }
@@ -203,6 +152,86 @@
     }
 }
 
+@OptIn(ExperimentalWearFoundationApi::class, ExperimentalWearMaterialApi::class)
+@Composable
+private fun SwipeToRevealChipExpandable(
+    modifier: Modifier = Modifier,
+    text: String,
+    revealState: RevealState,
+    onDeleteAction: () -> Unit,
+    onUndoDelete: () -> Unit,
+    onDuplicateAction: (() -> Unit)?,
+    onUndoDuplicate: (() -> Unit)?
+) {
+    SwipeToRevealChip(
+        modifier = modifier.semantics {
+                customActions = listOfNotNull(
+                    CustomAccessibilityAction("Delete") {
+                        onDeleteAction()
+                        true
+                    },
+                    onDuplicateAction?.let {
+                        CustomAccessibilityAction("Duplicate") {
+                            onDuplicateAction()
+                            true
+                        }
+                    }
+                )
+            },
+        revealState = revealState,
+        onFullSwipe = onDeleteAction,
+        primaryAction = {
+            SwipeToRevealPrimaryAction(
+                revealState = revealState,
+                icon = {
+                    Icon(
+                        SwipeToRevealDefaults.Delete,
+                        contentDescription = "Delete"
+                    )
+                },
+                label = { Text(text = "Delete") },
+                onClick = onDeleteAction,
+            )
+        },
+        secondaryAction = onDuplicateAction?.let {
+            {
+                SwipeToRevealSecondaryAction(
+                    revealState = revealState,
+                    content = {
+                        Icon(Icons.Outlined.Add, contentDescription = "Duplicate")
+                    },
+                    onClick = onDuplicateAction
+                )
+            }
+        },
+        undoPrimaryAction = {
+            SwipeToRevealUndoAction(
+                revealState = revealState,
+                label = { Text("Undo Delete") },
+                onClick = onUndoDelete
+            )
+        },
+        undoSecondaryAction = onUndoDuplicate?.let {
+            {
+                SwipeToRevealUndoAction(
+                    revealState = revealState,
+                    label = { Text("Undo Duplicate") },
+                    onClick = onUndoDuplicate
+                )
+            }
+        }
+    ) {
+        Chip(
+            onClick = { /*TODO*/ },
+            colors = ChipDefaults.secondaryChipColors(),
+            modifier = Modifier.fillMaxWidth(),
+            label = {
+                Text(text)
+            }
+        )
+    }
+}
+
 @Composable
 fun SwipeToRevealCards(swipeToDismissBoxState: SwipeToDismissBoxState) {
     val emailMap = mutableMapOf(
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
index 704f5fd..821d610 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/TestWatchFaceServices.kt
@@ -526,6 +526,7 @@
     testContext: Context,
     private var surfaceHolderOverride: SurfaceHolder
 ) : WatchFaceService() {
+    var lastComplicationType: ComplicationType? = null
 
     init {
         attachBaseContext(testContext)
@@ -605,7 +606,10 @@
                     CanvasType.HARDWARE,
                     16
                 ) {
-                override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) {}
+                override fun render(canvas: Canvas, bounds: Rect, zonedDateTime: ZonedDateTime) {
+                    lastComplicationType =
+                        complicationSlotsManager[123]!!.complicationData.value.type
+                }
 
                 override fun renderHighlightLayer(
                     canvas: Canvas,
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
index 2afa5d96..b77902c 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
@@ -1344,6 +1344,73 @@
 
         assertTrue(ObservableServiceC.awaitForServiceToBeBound(UPDATE_TIMEOUT_MILLIS))
     }
+
+    @Test
+    @RequiresApi(Build.VERSION_CODES.O_MR1)
+    fun overrideComplicationData() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
+            return
+        }
+        val wallpaperService =
+            TestComplicationProviderDefaultsWatchFaceService(context, surfaceHolder)
+        val interactiveInstance = getOrCreateTestSubject(wallpaperService)
+        interactiveInstance.updateComplicationData(
+            mapOf(123 to rangedValueComplicationBuilder().build())
+        )
+
+        interactiveInstance.overrideComplicationData(
+            mapOf(
+                123 to
+                    ShortTextComplicationData.Builder(
+                        PlainComplicationText.Builder("TEST").build(),
+                        ComplicationText.EMPTY
+                    )
+                        .build()
+            )
+        )
+
+        interactiveInstance.renderWatchFaceToBitmap(
+            RenderParameters(DrawMode.INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null),
+            Instant.ofEpochMilli(1234567),
+            null,
+            null
+        )
+        assertThat(wallpaperService.lastComplicationType).isEqualTo(ComplicationType.SHORT_TEXT)
+    }
+
+    @Test
+    @RequiresApi(Build.VERSION_CODES.O_MR1)
+    fun clearComplicationDataOverride() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
+            return
+        }
+        val wallpaperService =
+            TestComplicationProviderDefaultsWatchFaceService(context, surfaceHolder)
+        val interactiveInstance = getOrCreateTestSubject(wallpaperService)
+        interactiveInstance.updateComplicationData(
+            mapOf(123 to rangedValueComplicationBuilder().build())
+        )
+        interactiveInstance.overrideComplicationData(
+            mapOf(
+                123 to
+                    ShortTextComplicationData.Builder(
+                        PlainComplicationText.Builder("TEST").build(),
+                        ComplicationText.EMPTY
+                    )
+                        .build(),
+            )
+        )
+
+        interactiveInstance.clearComplicationDataOverride()
+
+        interactiveInstance.renderWatchFaceToBitmap(
+            RenderParameters(DrawMode.INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null),
+            Instant.ofEpochMilli(1234567),
+            null,
+            null
+        )
+        assertThat(wallpaperService.lastComplicationType).isEqualTo(ComplicationType.RANGED_VALUE)
+    }
 }
 
 @RunWith(AndroidJUnit4::class)
diff --git a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
index 6112bfe..44bd7b7 100644
--- a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
+++ b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
@@ -141,6 +141,23 @@
     public fun updateComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>)
 
     /**
+     * Sets override complications which are displayed until [clearComplicationDataOverride] is
+     * called. For editors this is more efficient than repeatedly calling [renderWatchFaceToBitmap]
+     * with complication data.
+     *
+     * While there are overrides [updateComplicationData] has no effect until
+     * [clearComplicationDataOverride] is called.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public fun overrideComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>) {}
+
+    /**
+     * Clears any overrides set by [overrideComplicationData].
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public fun clearComplicationDataOverride() {}
+
+    /**
      * Renders the watchface to a shared memory backed [Bitmap] with the given settings. Note this
      * will be fairly slow since either software canvas or glReadPixels will be invoked.
      *
@@ -433,6 +450,7 @@
     private var lastWatchFaceColors: WatchFaceColors? = null
     private var disconnectReason: Int? = null
     private var closed = false
+    private var overrideSlotIdToComplicationData = HashMap<Int, ComplicationData>()
 
     private val iWatchFaceListener =
         object : IWatchfaceListener.Stub() {
@@ -505,6 +523,31 @@
             )
         }
 
+    override fun overrideComplicationData(slotIdToComplicationData: Map<Int, ComplicationData>) {
+        if (iInteractiveWatchFace.apiVersion >= 11) {
+            iInteractiveWatchFace.overrideComplicationData(
+                slotIdToComplicationData.map {
+                    IdAndComplicationDataWireFormat(
+                        it.key,
+                        it.value.asWireComplicationData()
+                    )
+                }
+            )
+        } else {
+            for ((id, complicationData) in slotIdToComplicationData) {
+                overrideSlotIdToComplicationData[id] = complicationData
+            }
+        }
+    }
+
+    override fun clearComplicationDataOverride() {
+        if (iInteractiveWatchFace.apiVersion >= 11) {
+            iInteractiveWatchFace.clearComplicationDataOverride()
+        } else {
+            overrideSlotIdToComplicationData.clear()
+        }
+    }
+
     @RequiresApi(27)
     override fun renderWatchFaceToBitmap(
         renderParameters: RenderParameters,
@@ -519,7 +562,11 @@
                         renderParameters.toWireFormat(),
                         instant.toEpochMilli(),
                         userStyle?.toWireFormat(),
-                        idAndComplicationData?.map {
+                        if (iInteractiveWatchFace.apiVersion >= 11) {
+                            idAndComplicationData
+                        } else {
+                            mergeWithOverrideComplicationData(idAndComplicationData)
+                        }?.map {
                             IdAndComplicationDataWireFormat(
                                 it.key,
                                 it.value.asWireComplicationData()
@@ -530,6 +577,27 @@
             )
         }
 
+    private fun mergeWithOverrideComplicationData(
+        idAndComplicationData: Map<Int, ComplicationData>?
+    ): Map<Int, ComplicationData>? {
+        if (overrideSlotIdToComplicationData.isEmpty()) {
+            return idAndComplicationData
+        }
+
+        if (idAndComplicationData.isNullOrEmpty()) {
+            return overrideSlotIdToComplicationData
+        }
+
+        val merged = HashMap(overrideSlotIdToComplicationData)
+        for ((id, complicationData) in idAndComplicationData) {
+            if (merged.contains(id)) {
+                continue
+            }
+            merged[id] = complicationData
+        }
+        return merged
+    }
+
     override val isRemoteWatchFaceViewHostSupported = iInteractiveWatchFace.apiVersion >= 9
 
     @RequiresApi(Build.VERSION_CODES.R)
diff --git a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
index c06fdab8..7e61727 100644
--- a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
+++ b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
@@ -37,12 +37,12 @@
 interface IInteractiveWatchFace {
     // IMPORTANT NOTE: All methods must be given an explicit transaction id that must never change
     // in the future to remain binary backwards compatible.
-    // Next Id: 25
+    // Next Id: 28
 
     /**
      * API version number. This should be incremented every time a new method is added.
      */
-    const int API_VERSION = 10;
+    const int API_VERSION = 11;
 
     /** Indicates a "down" touch event on the watch face. */
     const int TAP_TYPE_DOWN = 0;
@@ -243,4 +243,22 @@
      * @since API version 10.
      */
     UserStyleFlavorsWireFormat getUserStyleFlavors() = 25;
+
+    /**
+     * Send override ComplicationData to be used until clearComplicationDataOverride is called.
+     * While overrides, any calls to updateComplicationData are deferred until
+     * clearComplicationDataOverride is called.
+     *
+     * @since API version 11.
+     */
+    oneway void overrideComplicationData(
+        in List<IdAndComplicationDataWireFormat> complicationData) = 26;
+
+    /**
+     * Clears any complicaton data set by overrideComplicationData, and activates any complications
+     * set by updateComplicationData.
+     *
+     * @since API version 11.
+     */
+    oneway void clearComplicationDataOverride() = 27;
 }
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
index 3c7ec5c..7cd7f68 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/control/InteractiveWatchFaceImpl.kt
@@ -24,6 +24,7 @@
 import androidx.annotation.RequiresApi
 import androidx.wear.watchface.TapEvent
 import androidx.wear.watchface.WatchFaceService
+import androidx.wear.watchface.complications.data.toApiComplicationData
 import androidx.wear.watchface.control.data.WatchFaceRenderParams
 import androidx.wear.watchface.data.IdAndComplicationDataWireFormat
 import androidx.wear.watchface.data.IdAndComplicationStateWireFormat
@@ -292,6 +293,22 @@
             }
         }
 
+    override fun overrideComplicationData(
+        complicationDatumWireFormats: List<IdAndComplicationDataWireFormat>
+    ): Unit = aidlMethod(TAG, "overrideComplicationData") {
+        engine?.overrideComplications(
+            complicationDatumWireFormats.associateBy(
+                { it.id },
+                { it.complicationData.toApiComplicationData() }
+            )
+        )
+    }
+
+    override fun clearComplicationDataOverride(): Unit =
+        aidlMethod(TAG, "overrideComplicationData") {
+            engine?.removeAnyComplicationOverrides()
+        }
+
     fun onDestroy() {
         // Note this is almost certainly called on the ui thread, from release() above.
         runBlocking {