Migrate pointerInput to Modifier.Node.

Test: Used existing tests.
Change-Id: I47f213927a6fde605c563d47af683694715eeab3
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
index ef7040b..592c0df 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/AwaitEachGestureTest.kt
@@ -25,6 +25,7 @@
 import androidx.compose.ui.input.pointer.PointerEventType
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.click
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performTouchInput
@@ -48,12 +49,14 @@
     @get:Rule
     val rule = createComposeRule()
 
+    private val tag = "pointerInputTag"
+
     @Test
     fun awaitEachGestureInternalCancellation() {
         val inputLatch = CountDownLatch(1)
         rule.setContent {
             Box(
-                Modifier.pointerInput(Unit) {
+                Modifier.testTag(tag).pointerInput(Unit) {
                     try {
                         var count = 0
                         coroutineScope {
@@ -79,6 +82,7 @@
             )
         }
         rule.waitForIdle()
+        rule.onNodeWithTag(tag).performTouchInput { click(Offset.Zero) }
         assertThat(inputLatch.await(1, TimeUnit.SECONDS)).isTrue()
     }
 
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
index 2fcb00d..5660223 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/ForEachGestureTest.kt
@@ -20,8 +20,13 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.size
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.click
 import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
@@ -45,16 +50,20 @@
     @get:Rule
     val rule = createComposeRule()
 
+    private val tag = "pointerInputTag"
+
     /**
-     * Make sure that an empty `forEachGesture` block does not cause a crash.
+     * Make sure that a single `forEachGesture` block does not cause a crash.
+     * Note: Is is no longer possible for an empty gesture since pointerInput() is started lazily.
      */
+    // TODO (jjw): Check with George that this test is needed anymore.
     @Test
-    fun testEmptyForEachGesture() {
+    fun testSingleTapForEachGesture() {
         val latch1 = CountDownLatch(2)
         val latch2 = CountDownLatch(1)
         rule.setContent {
             Box(
-                Modifier.pointerInput(Unit) {
+                Modifier.testTag(tag).pointerInput(Unit) {
                     forEachGesture {
                         if (latch1.count == 0L) {
                             // forEachGesture will loop infinitely with nothing in the middle
@@ -65,12 +74,17 @@
                     }
                 }.pointerInput(Unit) {
                     awaitPointerEventScope {
-                        assertTrue(currentEvent.changes.isEmpty())
+                        // there is no awaitPointerEvent() / loop here, so it will only
+                        // execute once.
+                        assertTrue(currentEvent.changes.size == 1)
                         latch2.countDown()
                     }
                 }.size(10.dp)
             )
         }
+        rule.waitForIdle()
+        rule.onNodeWithTag(tag).performTouchInput { click(Offset.Zero) }
+
         assertTrue(latch1.await(1, TimeUnit.SECONDS))
         assertTrue(latch2.await(1, TimeUnit.SECONDS))
     }
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt
new file mode 100644
index 0000000..8c4d47f
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TapGestureDetectorTest.kt
@@ -0,0 +1,905 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.gesture
+
+import androidx.compose.foundation.gestures.GestureCancellationException
+import androidx.compose.foundation.gestures.detectTapAndPress
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(JUnit4::class)
+class TapGestureDetectorTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private var pressed = false
+    private var released = false
+    private var canceled = false
+    private var tapped = false
+    private var doubleTapped = false
+    private var longPressed = false
+
+    /** The time before a long press gesture attempts to win. */
+    private val LongPressTimeoutMillis: Long = 500L
+
+    /**
+     * The maximum time from the start of the first tap to the start of the second
+     * tap in a double-tap gesture.
+     */
+    // TODO(shepshapard): In Android, this is actually the time from the first's up event
+    // to the second's down event, according to the ViewConfiguration docs.
+    private val DoubleTapTimeoutMillis: Long = 300L
+
+    private val util = layoutWithGestureDetector {
+        detectTapGestures(
+            onPress = {
+                pressed = true
+                if (tryAwaitRelease()) {
+                    released = true
+                } else {
+                    canceled = true
+                }
+            },
+            onTap = {
+                tapped = true
+            }
+        )
+    }
+
+    private val utilWithShortcut = layoutWithGestureDetector {
+        detectTapAndPress(
+            onPress = {
+                pressed = true
+                if (tryAwaitRelease()) {
+                    released = true
+                } else {
+                    canceled = true
+                }
+            },
+            onTap = {
+                tapped = true
+            }
+        )
+    }
+
+    private val allGestures = layoutWithGestureDetector {
+        detectTapGestures(
+            onPress = {
+                pressed = true
+                try {
+                    awaitRelease()
+                    released = true
+                } catch (_: GestureCancellationException) {
+                    canceled = true
+                }
+            },
+            onTap = { tapped = true },
+            onLongPress = { longPressed = true },
+            onDoubleTap = { doubleTapped = true }
+        )
+    }
+
+    private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+    private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+    private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+    @Before
+    fun setup() {
+        pressed = false
+        released = false
+        canceled = false
+        tapped = false
+        doubleTapped = false
+        longPressed = false
+    }
+
+    private fun layoutWithGestureDetector(
+        gestureDetector: suspend PointerInputScope.() -> Unit,
+    ): @Composable () -> Unit = {
+        CompositionLocalProvider(
+            LocalDensity provides Density(1f),
+            LocalViewConfiguration provides TestViewConfiguration(
+                minimumTouchTargetSize = DpSize.Zero
+            )
+        ) {
+            with(LocalDensity.current) {
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        // Some tests execute a lambda before the initial and final passes
+                        // so they are called here, higher up the chain, so that the
+                        // calls happen prior to the gestureDetector below. The lambdas
+                        // do things like consume events on the initial pass or validate
+                        // consumption on the final pass.
+                        .pointerInput(Unit) {
+                            awaitPointerEventScope {
+                                while (true) {
+                                    val event = awaitPointerEvent(PointerEventPass.Initial)
+                                    event.changes.forEach {
+                                        initialPass(it)
+                                    }
+                                    awaitPointerEvent(PointerEventPass.Final)
+                                    event.changes.forEach {
+                                        finalPass(it)
+                                    }
+                                }
+                            }
+                        }
+                        .wrapContentSize(AbsoluteAlignment.TopLeft)
+                        .size(10.toDp())
+                        .pointerInput(gestureDetector, gestureDetector)
+                        .testTag(TargetTag)
+                )
+            }
+        }
+    }
+
+    private fun performTouch(
+        initialPass: PointerInputChange.() -> Unit = nothingHandler,
+        finalPass: PointerInputChange.() -> Unit = nothingHandler,
+        block: TouchInjectionScope.() -> Unit
+    ) {
+        this.initialPass = initialPass
+        this.finalPass = finalPass
+        rule.onNodeWithTag(TargetTag).performTouchInput(block)
+        rule.waitForIdle()
+        this.initialPass = nothingHandler
+        this.finalPass = nothingHandler
+    }
+
+    /**
+     * Clicking in the region should result in the callback being invoked.
+     */
+    @Test
+    fun normalTap() {
+        rule.setContent(util)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertTrue(pressed)
+        assertFalse(tapped)
+        assertFalse(released)
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(tapped)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * Clicking in the region should result in the callback being invoked.
+     */
+    @Test
+    fun normalTap_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertTrue(pressed)
+        assertFalse(tapped)
+        assertFalse(released)
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(tapped)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * Clicking in the region should result in the callback being invoked.
+     */
+    @Test
+    fun normalTapWithAllGestures() {
+        rule.setContent(allGestures)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertTrue(pressed)
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(released)
+
+        // we have to wait for the double-tap timeout before we receive an event
+
+        assertFalse(tapped)
+        assertFalse(doubleTapped)
+
+        rule.mainClock.advanceTimeBy(DoubleTapTimeoutMillis + 10)
+
+        assertTrue(tapped)
+        assertFalse(doubleTapped)
+    }
+
+    /**
+     * Clicking in the region should result in the callback being invoked.
+     */
+    @Test
+    fun normalDoubleTap() {
+        rule.setContent(allGestures)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(tapped)
+        assertFalse(doubleTapped)
+
+        pressed = false
+        released = false
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(doubleTapped)
+        assertTrue(pressed)
+        assertTrue(released)
+    }
+
+    /**
+     * Long press in the region should result in the callback being invoked.
+     */
+    @Test
+    fun normalLongPress() {
+        rule.setContent(allGestures)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertTrue(pressed)
+
+        rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+        assertTrue(longPressed)
+
+        rule.mainClock.advanceTimeBy(500)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(doubleTapped)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * Pressing in the region, sliding out and then lifting should result in
+     * the callback not being invoked
+     */
+    @Test
+    fun tapMiss() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            moveTo(0, Offset(15f, 15f))
+        }
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(canceled)
+        assertFalse(released)
+        assertFalse(tapped)
+    }
+
+    /**
+     * Pressing in the region, sliding out and then lifting should result in
+     * the callback not being invoked
+     */
+    @Test
+    fun tapMiss_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            moveTo(0, Offset(15f, 15f))
+        }
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(canceled)
+        assertFalse(released)
+        assertFalse(tapped)
+    }
+
+    /**
+     * Pressing in the region, sliding out and then lifting should result in
+     * the callback not being invoked
+     */
+    @Test
+    fun longPressMiss() {
+        rule.setContent(allGestures)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            moveTo(0, Offset(15f, 15f))
+        }
+
+        rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+        assertFalse(tapped)
+        assertFalse(longPressed)
+        assertFalse(doubleTapped)
+    }
+
+    /**
+     * Pressing in the region, sliding out and then lifting should result in
+     * the callback not being invoked for double-tap
+     */
+    @Test
+    fun doubleTapMiss() {
+        rule.setContent(allGestures)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+
+        pressed = false
+        released = false
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch {
+            down(1, Offset(5f, 5f))
+            moveTo(1, Offset(15f, 15f))
+        }
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(1)
+        }
+
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+        assertTrue(tapped)
+        assertFalse(longPressed)
+        assertFalse(doubleTapped)
+    }
+
+    /**
+     * Pressing in the region, sliding out, then back in, then lifting
+     * should result the gesture being canceled.
+     */
+    @Test
+    fun tapOutAndIn() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            moveTo(0, Offset(15f, 15f))
+            moveTo(0, Offset(6f, 6f))
+        }
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Pressing in the region, sliding out, then back in, then lifting
+     * should result the gesture being canceled.
+     */
+    @Test
+    fun tapOutAndIn_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            moveTo(0, Offset(15f, 15f))
+            moveTo(0, Offset(6f, 6f))
+        }
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * After a first tap, a second tap should also be detected.
+     */
+    @Test
+    fun secondTap() {
+        rule.setContent(util)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+
+        tapped = false
+        pressed = false
+        released = false
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(1, Offset(4f, 4f))
+            up(1)
+        }
+
+        assertTrue(tapped)
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * After a first tap, a second tap should also be detected.
+     */
+    @Test
+    fun secondTap_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+
+        tapped = false
+        pressed = false
+        released = false
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+            up(0)
+        }
+
+        assertTrue(tapped)
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * Clicking in the region with the up already consumed should result in the callback not
+     * being invoked.
+     */
+    @Test
+    fun consumedUpTap() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+
+        performTouch(initialPass = { if (pressed != previousPressed) consume() }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Clicking in the region with the up already consumed should result in the callback not
+     * being invoked.
+     */
+    @Test
+    fun consumedUpTap_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+
+        performTouch(initialPass = { if (pressed != previousPressed) consume() }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Clicking in the region with the motion consumed should result in the callback not
+     * being invoked.
+     */
+    @Test
+    fun consumedMotionTap() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveTo(0, Offset(6f, 2f))
+        }
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Clicking in the region with the motion consumed should result in the callback not
+     * being invoked.
+     */
+    @Test
+    fun consumedMotionTap_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveTo(0, Offset(6f, 2f))
+        }
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    @Test
+    fun consumedChange_MotionTap() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveTo(0, Offset(6f, 2f))
+        }
+
+        rule.mainClock.advanceTimeBy(50)
+
+        performTouch {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Clicking in the region with the up already consumed should result in the callback not
+     * being invoked.
+     */
+    @Test
+    fun consumedChange_upTap() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertFalse(tapped)
+        assertTrue(pressed)
+
+        performTouch(initialPass = { consume() }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+        assertTrue(canceled)
+    }
+
+    /**
+     * Ensure that two-finger taps work.
+     */
+    @Test
+    fun twoFingerTap() {
+        rule.setContent(util)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(1f, 1f))
+        }
+
+        assertTrue(pressed)
+        pressed = false
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            down(1, Offset(9f, 5f))
+        }
+
+        assertFalse(pressed)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(1)
+        }
+
+        assertTrue(tapped)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * Ensure that two-finger taps work.
+     */
+    @Test
+    fun twoFingerTap_withShortcut() {
+        rule.setContent(utilWithShortcut)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            down(0, Offset(1f, 1f))
+        }
+
+        assertTrue(pressed)
+        pressed = false
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            down(1, Offset(9f, 5f))
+        }
+
+        assertFalse(pressed)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            up(1)
+        }
+
+        assertTrue(tapped)
+        assertTrue(released)
+        assertFalse(canceled)
+    }
+
+    /**
+     * A position change consumption on any finger should cause tap to cancel.
+     */
+    @Test
+    fun twoFingerTapCancel() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(1f, 1f))
+        }
+        assertTrue(pressed)
+
+        performTouch {
+            down(1, Offset(9f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveTo(0, Offset(5f, 5f))
+        }
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(canceled)
+
+        rule.mainClock.advanceTimeBy(50)
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(1)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+    }
+
+    /**
+     * A position change consumption on any finger should cause tap to cancel.
+     */
+    @Test
+    fun twoFingerTapCancel_withShortcut() {
+        rule.setContent(utilWithShortcut)
+        performTouch {
+            down(0, Offset(1f, 1f))
+        }
+
+        assertTrue(pressed)
+
+        performTouch {
+            down(1, Offset(9f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveTo(0, Offset(5f, 5f))
+        }
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(tapped)
+        assertTrue(canceled)
+
+        rule.mainClock.advanceTimeBy(50)
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(1)
+        }
+
+        assertFalse(tapped)
+        assertFalse(released)
+    }
+
+    /**
+     * Detect the second tap as long press.
+     */
+    @Test
+    fun secondTapLongPress() {
+        rule.setContent(allGestures)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            up(0)
+        }
+
+        assertTrue(pressed)
+        assertTrue(released)
+        assertFalse(canceled)
+        assertFalse(tapped)
+        assertFalse(doubleTapped)
+        assertFalse(longPressed)
+
+        pressed = false
+        released = false
+
+        rule.mainClock.advanceTimeBy(50)
+        performTouch {
+            down(1, Offset(5f, 5f))
+        }
+
+        assertTrue(pressed)
+
+        rule.mainClock.advanceTimeBy(LongPressTimeoutMillis + 10)
+
+        assertTrue(tapped)
+        assertTrue(longPressed)
+        assertFalse(released)
+        assertFalse(canceled)
+
+        rule.mainClock.advanceTimeBy(500)
+        performTouch {
+            up(1)
+        }
+        assertTrue(released)
+    }
+}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt
new file mode 100644
index 0000000..44a51df
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/gesture/TransformGestureDetectorTest.kt
@@ -0,0 +1,606 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.gesture
+
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(Parameterized::class)
+class TransformGestureDetectorTest(val panZoomLock: Boolean) {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun parameters() = arrayOf(false, true)
+    }
+
+    private var centroid = Offset.Zero
+    private var panned = false
+    private var panAmount = Offset.Zero
+    private var rotated = false
+    private var rotateAmount = 0f
+    private var zoomed = false
+    private var zoomAmount = 1f
+
+    private val util = layoutWithGestureDetector {
+        detectTransformGestures(
+            panZoomLock = panZoomLock
+        ) { c, pan, gestureZoom, gestureAngle ->
+            centroid = c
+            if (gestureAngle != 0f) {
+                rotated = true
+                rotateAmount += gestureAngle
+            }
+            if (gestureZoom != 1f) {
+                zoomed = true
+                zoomAmount *= gestureZoom
+            }
+            if (pan != Offset.Zero) {
+                panned = true
+                panAmount += pan
+            }
+        }
+    }
+
+    private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+    private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+    private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+    @Before
+    fun setup() {
+        panned = false
+        panAmount = Offset.Zero
+        rotated = false
+        rotateAmount = 0f
+        zoomed = false
+        zoomAmount = 1f
+    }
+
+    private fun layoutWithGestureDetector(
+        gestureDetector: suspend PointerInputScope.() -> Unit,
+    ): @Composable () -> Unit = {
+        CompositionLocalProvider(
+            LocalDensity provides Density(1f),
+            LocalViewConfiguration provides TestViewConfiguration(
+                minimumTouchTargetSize = DpSize.Zero
+            )
+        ) {
+            with(LocalDensity.current) {
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        // Some tests execute a lambda before the initial and final passes
+                        // so they are called here, higher up the chain, so that the
+                        // calls happen prior to the gestureDetector below. The lambdas
+                        // do things like consume events on the initial pass or validate
+                        // consumption on the final pass.
+                        .pointerInput(Unit) {
+                            awaitPointerEventScope {
+                                while (true) {
+                                    val event = awaitPointerEvent(PointerEventPass.Initial)
+                                    event.changes.forEach {
+                                        initialPass(it)
+                                    }
+                                    awaitPointerEvent(PointerEventPass.Final)
+                                    event.changes.forEach {
+                                        finalPass(it)
+                                    }
+                                }
+                            }
+                        }
+                        .wrapContentSize(AbsoluteAlignment.TopLeft)
+                        .size(1600.toDp())
+                        .pointerInput(gestureDetector, gestureDetector)
+                        .testTag(TargetTag)
+                )
+            }
+        }
+    }
+
+    private fun performTouch(
+        initialPass: PointerInputChange.() -> Unit = nothingHandler,
+        finalPass: PointerInputChange.() -> Unit = nothingHandler,
+        block: TouchInjectionScope.() -> Unit
+    ) {
+        this.initialPass = initialPass
+        this.finalPass = finalPass
+        rule.onNodeWithTag(TargetTag).performTouchInput(block)
+        rule.waitForIdle()
+        this.initialPass = nothingHandler
+        this.finalPass = nothingHandler
+    }
+
+    /**
+     * Single finger pan.
+     */
+    @Test
+    fun singleFingerPan() {
+        rule.setContent(util)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            down(0, Offset(5f, 5f))
+        }
+
+        assertFalse(panned)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            moveBy(0, Offset(12.7f, 12.7f))
+        }
+
+        assertFalse(panned)
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            moveBy(0, Offset(0.1f, 0.1f))
+        }
+
+        assertEquals(17.7f, centroid.x, 0.1f)
+        assertEquals(17.7f, centroid.y, 0.1f)
+        assertTrue(panned)
+        assertFalse(zoomed)
+        assertFalse(rotated)
+
+        assertTrue(panAmount.getDistance() < 1f)
+
+        panAmount = Offset.Zero
+
+        performTouch(finalPass = { assertTrue(isConsumed) }) {
+            moveBy(0, Offset(1f, 0f))
+        }
+
+        assertEquals(Offset(1f, 0f), panAmount)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            up(0)
+        }
+
+        assertFalse(rotated)
+        assertFalse(zoomed)
+    }
+
+    /**
+     * Multi-finger pan
+     */
+    @Test
+    fun multiFingerPanZoom() {
+        rule.setContent(util)
+
+        // [PointerId] needed later to assert whether or not a particular pointer id was consumed.
+        var pointerId0: PointerId? = null
+        var pointerId1: PointerId? = null
+
+        performTouch(
+            finalPass = {
+                pointerId0 = id
+                assertFalse(isConsumed)
+            }
+        ) {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(
+            finalPass = {
+                if (id != pointerId0) {
+                    pointerId1 = id
+                }
+                assertFalse(isConsumed)
+            }
+        ) {
+            down(1, Offset(25f, 25f))
+        }
+
+        assertFalse(panned)
+
+        performTouch(finalPass = { assertFalse(isConsumed) }) {
+            moveBy(0, Offset(13f, 13f))
+        }
+
+        // With the move below, we've now averaged enough movement (touchSlop is around 18.0)
+        performTouch(
+            finalPass = {
+                if (id == pointerId1) {
+                    assertTrue(isConsumed)
+                }
+            }
+        ) {
+            moveBy(1, Offset(13f, 13f))
+        }
+
+        assertEquals((5f + 25f + 13f) / 2f, centroid.x, 0.1f)
+        assertEquals((5f + 25f + 13f) / 2f, centroid.y, 0.1f)
+        assertTrue(panned)
+        assertTrue(zoomed)
+        assertFalse(rotated)
+
+        assertEquals(6.4f, panAmount.x, 0.1f)
+        assertEquals(6.4f, panAmount.y, 0.1f)
+
+        performTouch {
+            up(0)
+            up(1)
+        }
+    }
+
+    /**
+     * 2-pointer zoom
+     */
+    @Test
+    fun zoom2Pointer() {
+        rule.setContent(util)
+
+        // [PointerId] needed later to assert whether or not a particular pointer id was consumed.
+        var pointerId0: PointerId? = null
+        var pointerId1: PointerId? = null
+
+        performTouch(
+            finalPass = {
+                pointerId0 = id
+                assertFalse(isConsumed)
+            }
+        ) {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(
+            finalPass = {
+                if (id != pointerId0) {
+                    pointerId1 = id
+                    assertFalse(isConsumed)
+                }
+            }
+        ) {
+            down(1, Offset(25f, 5f))
+        }
+
+        performTouch(
+            finalPass = {
+                if (id == pointerId1) {
+                    assertFalse(isConsumed)
+                }
+            }
+        ) {
+            moveBy(1, Offset(35.95f, 0f))
+        }
+
+        performTouch(
+            finalPass = {
+                if (id == pointerId1) {
+                    assertTrue(isConsumed)
+                }
+            }
+        ) {
+            moveBy(1, Offset(0.1f, 0f))
+        }
+
+        assertTrue(panned)
+        assertTrue(zoomed)
+        assertFalse(rotated)
+
+        // both should be small movements
+        assertTrue(panAmount.getDistance() < 1f)
+        assertTrue(zoomAmount in 1f..1.1f)
+
+        zoomAmount = 1f
+        panAmount = Offset.Zero
+
+        performTouch(
+            finalPass = {
+                if (id == pointerId0) {
+                    assertTrue(isConsumed)
+                }
+            }
+        ) {
+            moveBy(0, Offset(-1f, 0f))
+        }
+
+        performTouch(
+            finalPass = {
+                if (id == pointerId1) {
+                    assertTrue(isConsumed)
+                }
+            }
+        ) {
+            moveBy(1, Offset(1f, 0f))
+        }
+
+        assertEquals(0f, panAmount.x, 0.01f)
+        assertEquals(0f, panAmount.y, 0.01f)
+
+        assertEquals(48f / 46f, zoomAmount, 0.01f)
+
+        performTouch {
+            up(0)
+            up(1)
+        }
+    }
+
+    /**
+     * 4-pointer zoom
+     */
+    @Test
+    fun zoom4Pointer() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(0f, 50f))
+        }
+
+        // just get past the touch slop
+        performTouch {
+            moveBy(0, Offset(-1000f, 0f))
+            moveBy(0, Offset(1000f, 0f))
+        }
+
+        panned = false
+        panAmount = Offset.Zero
+
+        performTouch {
+            down(1, Offset(100f, 50f))
+            down(2, Offset(50f, 0f))
+            down(3, Offset(50f, 100f))
+        }
+
+        performTouch {
+            moveBy(0, Offset(-50f, 0f))
+            moveBy(1, Offset(50f, 0f))
+        }
+
+        assertTrue(zoomed)
+        assertTrue(panned)
+
+        assertEquals(0f, panAmount.x, 0.1f)
+        assertEquals(0f, panAmount.y, 0.1f)
+        assertEquals(1.5f, zoomAmount, 0.1f)
+
+        performTouch {
+            moveBy(2, Offset(0f, -50f))
+            moveBy(3, Offset(0f, 50f))
+        }
+
+        assertEquals(0f, panAmount.x, 0.1f)
+        assertEquals(0f, panAmount.y, 0.1f)
+        assertEquals(2f, zoomAmount, 0.1f)
+
+        performTouch {
+            up(0)
+            up(1)
+            up(2)
+            up(3)
+        }
+    }
+
+    /**
+     * 2 pointer rotation.
+     */
+    @Test
+    fun rotation2Pointer() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(0f, 50f))
+            down(1, Offset(100f, 50f))
+
+            // Move
+            moveBy(0, Offset(50f, -50f))
+            moveBy(1, Offset(-50f, 50f))
+        }
+
+        // assume some of the above was touch slop
+        assertTrue(rotated)
+        rotateAmount = 0f
+        rotated = false
+        zoomAmount = 1f
+        panAmount = Offset.Zero
+
+        // now do the real rotation:
+        performTouch {
+            moveBy(0, Offset(-50f, 50f))
+            moveBy(1, Offset(50f, -50f))
+        }
+
+        performTouch {
+            up(0)
+            up(1)
+        }
+
+        assertTrue(rotated)
+        assertEquals(-90f, rotateAmount, 0.01f)
+        assertEquals(0f, panAmount.x, 0.1f)
+        assertEquals(0f, panAmount.y, 0.1f)
+        assertEquals(1f, zoomAmount, 0.1f)
+    }
+
+    /**
+     * 2 pointer rotation, with early panning.
+     */
+    @Test
+    fun rotation2PointerLock() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(0f, 50f))
+        }
+
+        // just get past the touch slop with panning
+        performTouch {
+            moveBy(0, Offset(-1000f, 0f))
+            moveBy(0, Offset(1000f, 0f))
+        }
+
+        performTouch {
+            down(1, Offset(100f, 50f))
+        }
+
+        // now do the rotation:
+        performTouch {
+            moveBy(0, Offset(50f, -50f))
+            moveBy(1, Offset(-50f, 50f))
+        }
+
+        performTouch {
+            up(0)
+            up(1)
+        }
+
+        if (panZoomLock) {
+            assertFalse(rotated)
+        } else {
+            assertTrue(rotated)
+            assertEquals(90f, rotateAmount, 0.01f)
+        }
+        assertEquals(0f, panAmount.x, 0.1f)
+        assertEquals(0f, panAmount.y, 0.1f)
+        assertEquals(1f, zoomAmount, 0.1f)
+    }
+
+    /**
+     * Adding or removing a pointer won't change the current values
+     */
+    @Test
+    fun noChangeOnPointerDownUp() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(0f, 50f))
+            down(1, Offset(100f, 50f))
+
+            moveBy(0, Offset(50f, -50f))
+            moveBy(1, Offset(-50f, 50f))
+        }
+
+        // now we've gotten past the touch slop
+        rotated = false
+        panned = false
+        zoomed = false
+
+        performTouch {
+            down(2, Offset(0f, 50f))
+        }
+
+        assertFalse(rotated)
+        assertFalse(panned)
+        assertFalse(zoomed)
+
+        performTouch {
+            down(3, Offset(100f, 50f))
+        }
+
+        assertFalse(rotated)
+        assertFalse(panned)
+        assertFalse(zoomed)
+
+        performTouch {
+            up(0)
+            up(1)
+            up(2)
+            up(3)
+        }
+
+        assertFalse(rotated)
+        assertFalse(panned)
+        assertFalse(zoomed)
+    }
+
+    /**
+     * Consuming position during touch slop will cancel the current gesture.
+     */
+    @Test
+    fun touchSlopCancel() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveBy(0, Offset(50f, 0f))
+        }
+
+        performTouch {
+            up(0)
+        }
+
+        assertFalse(panned)
+        assertFalse(zoomed)
+        assertFalse(rotated)
+    }
+
+    /**
+     * Consuming position after touch slop will cancel the current gesture.
+     */
+    @Test
+    fun afterTouchSlopCancel() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+        }
+
+        performTouch {
+            moveBy(0, Offset(50f, 0f))
+        }
+
+        performTouch(initialPass = { consume() }) {
+            moveBy(0, Offset(50f, 0f))
+        }
+
+        performTouch {
+            up(0)
+        }
+
+        assertTrue(panned)
+        assertFalse(zoomed)
+        assertFalse(rotated)
+        assertEquals(50f, panAmount.x, 0.1f)
+    }
+}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
new file mode 100644
index 0000000..a7b9fc5
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.text
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.AbsoluteAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.TouchInjectionScope
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import com.google.common.truth.Correspondence
+import com.google.common.truth.IterableSubject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+private const val TargetTag = "TargetLayout"
+
+@RunWith(JUnit4::class)
+class PointerMoveDetectorTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val actualMoves = mutableListOf()
+
+    private val util = layoutWithGestureDetector {
+        detectMoves { actualMoves.add(it) }
+    }
+
+    private val nothingHandler: PointerInputChange.() -> Unit = {}
+
+    private var initialPass: PointerInputChange.() -> Unit = nothingHandler
+    private var finalPass: PointerInputChange.() -> Unit = nothingHandler
+
+    @Before
+    fun setup() {
+        actualMoves.clear()
+    }
+
+    private fun layoutWithGestureDetector(
+        gestureDetector: suspend PointerInputScope.() -> Unit,
+    ): @Composable () -> Unit = {
+        CompositionLocalProvider(
+            LocalDensity provides Density(1f),
+            LocalViewConfiguration provides TestViewConfiguration(
+                minimumTouchTargetSize = DpSize.Zero
+            )
+        ) {
+            with(LocalDensity.current) {
+                Box(
+                    Modifier
+                        .fillMaxSize()
+                        // Some tests execute a lambda before the initial and final passes
+                        // so they are called here, higher up the chain, so that the
+                        // calls happen prior to the gestureDetector below. The lambdas
+                        // do things like consume events on the initial pass or validate
+                        // consumption on the final pass.
+                        .pointerInput(Unit) {
+                            awaitPointerEventScope {
+                                while (true) {
+                                    val event = awaitPointerEvent(PointerEventPass.Initial)
+                                    event.changes.forEach {
+                                        initialPass(it)
+                                    }
+                                    awaitPointerEvent(PointerEventPass.Final)
+                                    event.changes.forEach {
+                                        finalPass(it)
+                                    }
+                                }
+                            }
+                        }
+                        .wrapContentSize(AbsoluteAlignment.TopLeft)
+                        .size(100.toDp())
+                        .pointerInput(gestureDetector, gestureDetector)
+                        .testTag(TargetTag)
+                )
+            }
+        }
+    }
+
+    private fun performTouch(
+        initialPass: PointerInputChange.() -> Unit = nothingHandler,
+        finalPass: PointerInputChange.() -> Unit = nothingHandler,
+        block: TouchInjectionScope.() -> Unit
+    ) {
+        this.initialPass = initialPass
+        this.finalPass = finalPass
+        rule.onNodeWithTag(TargetTag).performTouchInput(block)
+        rule.waitForIdle()
+        this.initialPass = nothingHandler
+        this.finalPass = nothingHandler
+    }
+
+    @Test
+    fun whenSimpleMovement_allMovesAreReported() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+
+            moveTo(0, Offset(4f, 4f))
+            moveTo(0, Offset(3f, 3f))
+            moveTo(0, Offset(2f, 2f))
+            moveTo(0, Offset(1f, 1f))
+
+            up(0)
+        }
+
+        assertThat(actualMoves).hasEqualOffsets(
+            listOf(
+                Offset(4f, 4f),
+                Offset(3f, 3f),
+                Offset(2f, 2f),
+                Offset(1f, 1f),
+            )
+        )
+    }
+
+    @Test
+    fun whenMultiplePointers_onlyUseFirst() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f))
+            down(1, Offset(6f, 6f))
+
+            moveTo(0, Offset(4f, 4f))
+            moveTo(1, Offset(7f, 7f))
+
+            moveTo(0, Offset(3f, 3f))
+            moveTo(1, Offset(8f, 8f))
+
+            moveTo(0, Offset(2f, 2f))
+            moveTo(1, Offset(9f, 9f))
+
+            moveTo(0, Offset(1f, 1f))
+            moveTo(1, Offset(10f, 10f))
+
+            up(0)
+            up(1)
+        }
+
+        assertThat(actualMoves).hasEqualOffsets(
+            listOf(
+                Offset(4f, 4f),
+                Offset(3f, 3f),
+                Offset(2f, 2f),
+                Offset(1f, 1f),
+            )
+        )
+    }
+
+    @Test
+    fun whenMultiplePointers_thenFirstReleases_handOffToNextPointer() {
+        rule.setContent(util)
+
+        performTouch {
+            down(0, Offset(5f, 5f)) // ignored because not a move
+
+            moveTo(0, Offset(4f, 4f)) // used
+            moveTo(0, Offset(3f, 3f)) // used
+
+            down(1, Offset(4f, 4f)) // ignored because still tracking pointer id 0
+
+            moveTo(0, Offset(2f, 2f)) // used
+            moveTo(1, Offset(3f, 3f)) // ignored because still tracking pointer id 0
+
+            up(0) // ignored because not a move
+
+            moveTo(1, Offset(2f, 2f)) // ignored b/c equal to the previous used move
+            moveTo(1, Offset(1f, 1f)) // used
+
+            up(1) // ignored because not a move
+        }
+
+        assertThat(actualMoves).hasEqualOffsets(
+            listOf(
+                Offset(4f, 4f),
+                Offset(3f, 3f),
+                Offset(2f, 2f),
+                Offset(1f, 1f),
+            )
+        )
+    }
+
+    private fun IterableSubject.hasEqualOffsets(expectedMoves: List) {
+        comparingElementsUsing(offsetCorrespondence)
+            .containsExactly(*expectedMoves.toTypedArray())
+            .inOrder()
+    }
+
+    private val offsetCorrespondence: Correspondence = Correspondence.from(
+        { o1, o2 -> o1!!.x == o2!!.x && o1.y == o2.y },
+        "has the offset of",
+    )
+}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
deleted file mode 100644
index 4d1347a..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/SuspendingGestureTestUtil.kt
+++ /dev/null
@@ -1,359 +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.foundation.gestures
-
-import androidx.compose.runtime.Applier
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.Composer
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.ControlledComposition
-import androidx.compose.runtime.InternalComposeApi
-import androidx.compose.runtime.MonotonicFrameClock
-import androidx.compose.runtime.Recomposer
-import androidx.compose.runtime.currentComposer
-import androidx.compose.runtime.withRunningRecomposer
-import androidx.compose.testutils.TestViewConfiguration
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.pointer.PointerEvent
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.PointerId
-import androidx.compose.ui.input.pointer.PointerInputChange
-import androidx.compose.ui.input.pointer.PointerInputFilter
-import androidx.compose.ui.input.pointer.PointerInputScope
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.materialize
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.IntSize
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.yield
-
-/**
- * Manages suspending pointer input for a single gesture detector, passed in
- * [gestureDetector]. The [width] and [height] of the LayoutNode may
- * be provided.
- */
-internal class SuspendingGestureTestUtil(
-    val width: Int = 10,
-    val height: Int = 10,
-    private val gestureDetector: suspend PointerInputScope.() -> Unit,
-) {
-    private var nextPointerId = 0L
-    private val activePointers = mutableMapOf()
-    private var pointerInputFilter: PointerInputFilter? = null
-    private var lastTime = 0L
-    private var isExecuting = false
-
-    /**
-     * Executes the block in composition, creating a gesture detector from
-     * [gestureDetector]. The [down], [moveTo], and [up] can then be
-     * called within [block].
-     *
-     * This is not reentrant.
-     */
-    @OptIn(ExperimentalCoroutinesApi::class)
-    fun executeInComposition(block: suspend SuspendingGestureTestUtil.() -> Unit) {
-        check(!isExecuting) { "executeInComposition is not reentrant" }
-        try {
-            isExecuting = true
-            runTest {
-                val frameClock = TestFrameClock()
-
-                withContext(frameClock) {
-                    composeGesture(block)
-                }
-            }
-        } finally {
-            isExecuting = false
-            pointerInputFilter = null
-            lastTime = 0
-            activePointers.clear()
-        }
-    }
-
-    private suspend fun composeGesture(block: suspend SuspendingGestureTestUtil.() -> Unit) {
-        withRunningRecomposer { recomposer ->
-            compose(recomposer) {
-                CompositionLocalProvider(
-                    LocalDensity provides Density(1f),
-                    LocalViewConfiguration provides TestViewConfiguration(
-                        minimumTouchTargetSize = DpSize.Zero
-                    )
-                ) {
-                    pointerInputFilter = currentComposer
-                        .materialize(Modifier.pointerInput(Unit, gestureDetector)) as
-                        PointerInputFilter
-                }
-            }
-            yield()
-            block()
-            // Pointer input effects will loop indefinitely; fully cancel them.
-            recomposer.cancel()
-        }
-    }
-
-    /**
-     * Creates a new pointer being down at [timeDiffMillis] from the previous event. The position
-     * [x], [y] is used for the touch point. The [PointerInputChange] may be mutated
-     * prior to invoking the change on all passes in [initial], if provided. All other "down"
-     * pointers will also be included in the change event.
-     */
-    suspend fun down(
-        x: Float,
-        y: Float,
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange {
-        lastTime += timeDiffMillis
-        val change = PointerInputChange(
-            id = PointerId(nextPointerId++),
-            uptimeMillis = lastTime,
-            position = Offset(x, y),
-            pressed = true,
-            previousUptimeMillis = lastTime,
-            previousPosition = Offset(x, y),
-            previousPressed = false,
-            isInitiallyConsumed = false
-        )
-        activePointers[change.id] = change
-        invokeOverAllPasses(change, initial, main, final)
-        return change
-    }
-
-    /**
-     * Creates a new pointer being down at [timeDiffMillis] from the previous event. The position
-     * [offset] is used for the touch point. The [PointerInputChange] may be mutated
-     * prior to invoking the change on all passes in [initial], if provided. All other "down"
-     * pointers will also be included in the change event.
-     */
-    suspend fun down(
-        offset: Offset = Offset.Zero,
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange {
-        return down(offset.x, offset.y, timeDiffMillis, main, final, initial)
-    }
-
-    /**
-     * Raises the pointer. [initial] will be called on the [PointerInputChange] prior to the
-     * event being invoked on all passes. After [up], the event will no longer participate
-     * in other events. [timeDiffMillis] indicates the time from the previous event that
-     * the [up] takes place.
-     */
-    suspend fun PointerInputChange.up(
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange {
-        lastTime += timeDiffMillis
-        val change = PointerInputChange(
-            id = id,
-            previousUptimeMillis = uptimeMillis,
-            previousPressed = pressed,
-            previousPosition = position,
-            uptimeMillis = lastTime,
-            pressed = false,
-            position = position,
-            isInitiallyConsumed = false
-        )
-        activePointers[change.id] = change
-        invokeOverAllPasses(change, initial, main, final)
-        activePointers.remove(change.id)
-        return change
-    }
-
-    /**
-     * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
-     * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
-     * on all passes.
-     */
-    suspend fun PointerInputChange.moveTo(
-        x: Float,
-        y: Float,
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange {
-        lastTime += timeDiffMillis
-        val change = PointerInputChange(
-            id = id,
-            previousUptimeMillis = uptimeMillis,
-            previousPosition = position,
-            previousPressed = pressed,
-            uptimeMillis = lastTime,
-            position = Offset(x, y),
-            pressed = true,
-            isInitiallyConsumed = false
-        )
-        initial(change)
-        activePointers[change.id] = change
-        invokeOverAllPasses(change, initial, main, final)
-        return change
-    }
-
-    /**
-     * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
-     * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
-     * on all passes.
-     */
-    suspend fun PointerInputChange.moveTo(
-        offset: Offset,
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange = moveTo(offset.x, offset.y, timeDiffMillis, main, final, initial)
-
-    /**
-     * Moves an existing [down] pointer to a new position at [timeDiffMillis] from the most recent
-     * event. [initial] will be called on the [PointerInputChange] prior to invoking the event
-     * on all passes.
-     */
-    suspend fun PointerInputChange.moveBy(
-        offset: Offset,
-        timeDiffMillis: Long = 10,
-        main: PointerInputChange.() -> Unit = {},
-        final: PointerInputChange.() -> Unit = {},
-        initial: PointerInputChange.() -> Unit = {}
-    ): PointerInputChange = moveTo(
-        position.x + offset.x,
-        position.y + offset.y,
-        timeDiffMillis,
-        main,
-        final,
-        initial
-    )
-
-    /**
-     * Removes all pointers from the active pointers. This can simulate a faulty pointer stream
-     * for robustness testing.
-     */
-    fun clearPointerStream() {
-        activePointers.clear()
-    }
-
-    /**
-     * Updates all changes so that all events are at the current time.
-     */
-    private fun updateCurrentTime() {
-        val currentTime = lastTime
-        activePointers.entries.forEach { entry ->
-            val change = entry.value
-            if (change.uptimeMillis != currentTime) {
-                entry.setValue(
-                    PointerInputChange(
-                        id = change.id,
-                        previousUptimeMillis = change.uptimeMillis,
-                        previousPressed = change.pressed,
-                        previousPosition = change.position,
-                        uptimeMillis = currentTime,
-                        pressed = change.pressed,
-                        position = change.position,
-                        isInitiallyConsumed = false
-                    )
-                )
-            }
-        }
-    }
-
-    /**
-     * Invokes events for all passes.
-     */
-    private suspend fun invokeOverAllPasses(
-        change: PointerInputChange,
-        initial: PointerInputChange.() -> Unit,
-        main: PointerInputChange.() -> Unit,
-        final: PointerInputChange.() -> Unit
-    ) {
-        updateCurrentTime()
-        val event = PointerEvent(activePointers.values.toList())
-        val size = IntSize(width, height)
-
-        change.initial()
-        pointerInputFilter?.onPointerEvent(event, PointerEventPass.Initial, size)
-        yield()
-        change.main()
-        pointerInputFilter?.onPointerEvent(event, PointerEventPass.Main, size)
-        yield()
-        change.final()
-        pointerInputFilter?.onPointerEvent(event, PointerEventPass.Final, size)
-        yield()
-    }
-
-    @OptIn(InternalComposeApi::class)
-    private fun compose(
-        recomposer: Recomposer,
-        block: @Composable () -> Unit
-    ) {
-        ControlledComposition(
-            EmptyApplier(),
-            recomposer
-        ).apply {
-            composeContent {
-                @Suppress("UNCHECKED_CAST")
-                val fn = block as (Composer, Int) -> Unit
-                fn(currentComposer, 0)
-            }
-            applyChanges()
-            verifyConsistent()
-        }
-    }
-
-    internal class TestFrameClock : MonotonicFrameClock {
-
-        private val frameCh = Channel()
-
-        @Suppress("unused")
-        suspend fun frame(frameTimeNanos: Long) {
-            frameCh.send(frameTimeNanos)
-        }
-
-        override suspend fun  withFrameNanos(onFrame: (Long) -> R): R =
-            onFrame(frameCh.receive())
-    }
-
-    class EmptyApplier : Applier {
-        override val current: Unit = Unit
-        override fun down(node: Unit) {}
-        override fun up() {}
-        override fun insertTopDown(index: Int, instance: Unit) {
-            error("Unexpected")
-        }
-        override fun insertBottomUp(index: Int, instance: Unit) {
-            error("Unexpected")
-        }
-        override fun remove(index: Int, count: Int) {
-            error("Unexpected")
-        }
-        override fun move(from: Int, to: Int, count: Int) {
-            error("Unexpected")
-        }
-        override fun clear() {}
-    }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt
deleted file mode 100644
index 6d1613b..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TapGestureDetectorTest.kt
+++ /dev/null
@@ -1,649 +0,0 @@
-/*
- * Copyright 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT 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.gestures
-
-import kotlinx.coroutines.delay
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4::class)
-class TapGestureDetectorTest {
-    private var pressed = false
-    private var released = false
-    private var canceled = false
-    private var tapped = false
-    private var doubleTapped = false
-    private var longPressed = false
-
-    /** The time before a long press gesture attempts to win. */
-    private val LongPressTimeoutMillis: Long = 500L
-
-    /**
-     * The maximum time from the start of the first tap to the start of the second
-     * tap in a double-tap gesture.
-     */
-// TODO(shepshapard): In Android, this is actually the time from the first's up event
-// to the second's down event, according to the ViewConfiguration docs.
-    private val DoubleTapTimeoutMillis: Long = 300L
-
-    private val util = SuspendingGestureTestUtil {
-        detectTapGestures(
-            onPress = {
-                pressed = true
-                if (tryAwaitRelease()) {
-                    released = true
-                } else {
-                    canceled = true
-                }
-            },
-            onTap = {
-                tapped = true
-            }
-        )
-    }
-
-    private val utilWithShortcut = SuspendingGestureTestUtil {
-        detectTapAndPress(
-            onPress = {
-                pressed = true
-                if (tryAwaitRelease()) {
-                    released = true
-                } else {
-                    canceled = true
-                }
-            },
-            onTap = {
-                tapped = true
-            }
-        )
-    }
-
-    private val allGestures = SuspendingGestureTestUtil {
-        detectTapGestures(
-            onPress = {
-                pressed = true
-                try {
-                    awaitRelease()
-                    released = true
-                } catch (_: GestureCancellationException) {
-                    canceled = true
-                }
-            },
-            onTap = { tapped = true },
-            onLongPress = { longPressed = true },
-            onDoubleTap = { doubleTapped = true }
-        )
-    }
-
-    @Before
-    fun setup() {
-        pressed = false
-        released = false
-        canceled = false
-        tapped = false
-        doubleTapped = false
-        longPressed = false
-    }
-
-    /**
-     * Clicking in the region should result in the callback being invoked.
-     */
-    @Test
-    fun normalTap() = util.executeInComposition {
-        val down = down(5f, 5f)
-        assertTrue(down.isConsumed)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-        assertFalse(tapped)
-        assertFalse(released)
-
-        val up = down.up(50)
-        assertTrue(up.isConsumed)
-        assertTrue(up.isConsumed)
-
-        assertTrue(tapped)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * Clicking in the region should result in the callback being invoked.
-     */
-    @Test
-    fun normalTap_withShortcut() = utilWithShortcut.executeInComposition {
-        val down = down(5f, 5f)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-        assertFalse(tapped)
-        assertFalse(released)
-
-        val up = down.up(50)
-        assertTrue(up.isConsumed)
-
-        assertTrue(tapped)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * Clicking in the region should result in the callback being invoked.
-     */
-    @Test
-    fun normalTapWithAllGestures() = allGestures.executeInComposition {
-        val down = down(5f, 5f)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-
-        val up = down.up(50)
-        assertTrue(up.isConsumed)
-
-        assertTrue(released)
-
-        // we have to wait for the double-tap timeout before we receive an event
-
-        assertFalse(tapped)
-        assertFalse(doubleTapped)
-
-        delay(DoubleTapTimeoutMillis + 10)
-
-        assertTrue(tapped)
-        assertFalse(doubleTapped)
-    }
-
-    /**
-     * Clicking in the region should result in the callback being invoked.
-     */
-    @Test
-    fun normalDoubleTap() = allGestures.executeInComposition {
-        val up = down(5f, 5f)
-            .up()
-        assertTrue(up.isConsumed)
-
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(tapped)
-        assertFalse(doubleTapped)
-
-        pressed = false
-        released = false
-
-        val up2 = down(5f, 5f, 50)
-            .up()
-        assertTrue(up2.isConsumed)
-
-        assertFalse(tapped)
-        assertTrue(doubleTapped)
-        assertTrue(pressed)
-        assertTrue(released)
-    }
-
-    /**
-     * Long press in the region should result in the callback being invoked.
-     */
-    @Test
-    fun normalLongPress() = allGestures.executeInComposition {
-        val down = down(5f, 5f)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-        delay(LongPressTimeoutMillis + 10)
-
-        assertTrue(longPressed)
-
-        val up = down.up(500)
-        assertTrue(up.isConsumed)
-
-        assertFalse(tapped)
-        assertFalse(doubleTapped)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * Pressing in the region, sliding out and then lifting should result in
-     * the callback not being invoked
-     */
-    @Test
-    fun tapMiss() = util.executeInComposition {
-        val up = down(5f, 5f)
-            .moveTo(15f, 15f)
-            .up()
-
-        assertTrue(pressed)
-        assertTrue(canceled)
-        assertFalse(released)
-        assertFalse(tapped)
-        assertFalse(up.isConsumed)
-        assertFalse(up.isConsumed)
-    }
-
-    /**
-     * Pressing in the region, sliding out and then lifting should result in
-     * the callback not being invoked
-     */
-    @Test
-    fun tapMiss_withShortcut() = utilWithShortcut.executeInComposition {
-        val up = down(5f, 5f)
-            .moveTo(15f, 15f)
-            .up()
-
-        assertTrue(pressed)
-        assertTrue(canceled)
-        assertFalse(released)
-        assertFalse(tapped)
-        assertFalse(up.isConsumed)
-    }
-
-    /**
-     * Pressing in the region, sliding out and then lifting should result in
-     * the callback not being invoked
-     */
-    @Test
-    fun longPressMiss() = allGestures.executeInComposition {
-        val pointer = down(5f, 5f)
-            .moveTo(15f, 15f)
-
-        delay(DoubleTapTimeoutMillis + 10)
-        val up = pointer.up()
-        assertFalse(up.isConsumed)
-
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-        assertFalse(tapped)
-        assertFalse(longPressed)
-        assertFalse(doubleTapped)
-    }
-
-    /**
-     * Pressing in the region, sliding out and then lifting should result in
-     * the callback not being invoked for double-tap
-     */
-    @Test
-    fun doubleTapMiss() = allGestures.executeInComposition {
-        val up1 = down(5f, 5f).up()
-        assertTrue(up1.isConsumed)
-
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-
-        pressed = false
-        released = false
-
-        val up2 = down(5f, 5f, 50)
-            .moveTo(15f, 15f)
-            .up()
-
-        assertFalse(up2.isConsumed)
-
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-        assertTrue(tapped)
-        assertFalse(longPressed)
-        assertFalse(doubleTapped)
-    }
-
-    /**
-     * Pressing in the region, sliding out, then back in, then lifting
-     * should result the gesture being canceled.
-     */
-    @Test
-    fun tapOutAndIn() = util.executeInComposition {
-        val up = down(5f, 5f)
-            .moveTo(15f, 15f)
-            .moveTo(6f, 6f)
-            .up()
-
-        assertFalse(tapped)
-        assertFalse(up.isConsumed)
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Pressing in the region, sliding out, then back in, then lifting
-     * should result the gesture being canceled.
-     */
-    @Test
-    fun tapOutAndIn_withShortcut() = utilWithShortcut.executeInComposition {
-        val up = down(5f, 5f)
-            .moveTo(15f, 15f)
-            .moveTo(6f, 6f)
-            .up()
-
-        assertFalse(tapped)
-        assertFalse(up.isConsumed)
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * After a first tap, a second tap should also be detected.
-     */
-    @Test
-    fun secondTap() = util.executeInComposition {
-        down(5f, 5f)
-            .up()
-
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-
-        tapped = false
-        pressed = false
-        released = false
-
-        val up2 = down(4f, 4f)
-            .up()
-        assertTrue(tapped)
-        assertTrue(up2.isConsumed)
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * After a first tap, a second tap should also be detected.
-     */
-    @Test
-    fun secondTap_withShortcut() = utilWithShortcut.executeInComposition {
-        down(5f, 5f)
-            .up()
-
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-
-        tapped = false
-        pressed = false
-        released = false
-
-        val up2 = down(4f, 4f)
-            .up()
-        assertTrue(tapped)
-        assertTrue(up2.isConsumed)
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * Clicking in the region with the up already consumed should result in the callback not
-     * being invoked.
-     */
-    @Test
-    fun consumedUpTap() = util.executeInComposition {
-        val down = down(5f, 5f)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-
-        down.up {
-            if (pressed != previousPressed) consume()
-        }
-
-        assertFalse(tapped)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Clicking in the region with the up already consumed should result in the callback not
-     * being invoked.
-     */
-    @Test
-    fun consumedUpTap_withShortcut() = utilWithShortcut.executeInComposition {
-        val down = down(5f, 5f)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-
-        down.up {
-            if (pressed != previousPressed) consume()
-        }
-
-        assertFalse(tapped)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Clicking in the region with the motion consumed should result in the callback not
-     * being invoked.
-     */
-    @Test
-    fun consumedMotionTap() = util.executeInComposition {
-        down(5f, 5f)
-            .moveTo(6f, 2f) {
-                consume()
-            }
-            .up(50)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Clicking in the region with the motion consumed should result in the callback not
-     * being invoked.
-     */
-    @Test
-    fun consumedMotionTap_withShortcut() = utilWithShortcut.executeInComposition {
-        down(5f, 5f)
-            .moveTo(6f, 2f) {
-                consume()
-            }
-            .up(50)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    @Test
-    fun consumedChange_MotionTap() = util.executeInComposition {
-        down(5f, 5f)
-            .moveTo(6f, 2f) {
-                consume()
-            }
-            .up(50)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Clicking in the region with the up already consumed should result in the callback not
-     * being invoked.
-     */
-    @Test
-    fun consumedChange_upTap() = util.executeInComposition {
-        val down = down(5f, 5f)
-
-        assertFalse(tapped)
-        assertTrue(pressed)
-
-        down.up {
-            consume()
-        }
-
-        assertFalse(tapped)
-        assertFalse(released)
-        assertTrue(canceled)
-    }
-
-    /**
-     * Ensure that two-finger taps work.
-     */
-    @Test
-    fun twoFingerTap() = util.executeInComposition {
-        val down = down(1f, 1f)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-        pressed = false
-
-        val down2 = down(9f, 5f)
-        assertFalse(down2.isConsumed)
-        assertFalse(down2.isConsumed)
-
-        assertFalse(pressed)
-
-        val up = down.up()
-        assertFalse(up.isConsumed)
-        assertFalse(up.isConsumed)
-        assertFalse(tapped)
-        assertFalse(released)
-
-        val up2 = down2.up()
-        assertTrue(up2.isConsumed)
-        assertTrue(up2.isConsumed)
-
-        assertTrue(tapped)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * Ensure that two-finger taps work.
-     */
-    @Test
-    fun twoFingerTap_withShortcut() = utilWithShortcut.executeInComposition {
-        val down = down(1f, 1f)
-        assertTrue(down.isConsumed)
-
-        assertTrue(pressed)
-        pressed = false
-
-        val down2 = down(9f, 5f)
-        assertFalse(down2.isConsumed)
-
-        assertFalse(pressed)
-
-        val up = down.up()
-        assertFalse(up.isConsumed)
-        assertFalse(tapped)
-        assertFalse(released)
-
-        val up2 = down2.up()
-        assertTrue(up2.isConsumed)
-
-        assertTrue(tapped)
-        assertTrue(released)
-        assertFalse(canceled)
-    }
-
-    /**
-     * A position change consumption on any finger should cause tap to cancel.
-     */
-    @Test
-    fun twoFingerTapCancel() = util.executeInComposition {
-        val down = down(1f, 1f)
-
-        assertTrue(pressed)
-
-        val down2 = down(9f, 5f)
-
-        val up = down.moveTo(5f, 5f) {
-            consume()
-        }.up()
-        assertFalse(up.isConsumed)
-
-        assertFalse(tapped)
-        assertTrue(canceled)
-
-        val up2 = down2.up(50)
-        assertFalse(up2.isConsumed)
-
-        assertFalse(tapped)
-        assertFalse(released)
-    }
-
-    /**
-     * A position change consumption on any finger should cause tap to cancel.
-     */
-    @Test
-    fun twoFingerTapCancel_withShortcut() = utilWithShortcut.executeInComposition {
-        val down = down(1f, 1f)
-
-        assertTrue(pressed)
-
-        val down2 = down(9f, 5f)
-
-        val up = down.moveTo(5f, 5f) {
-            consume()
-        }.up()
-        assertFalse(up.isConsumed)
-
-        assertFalse(tapped)
-        assertTrue(canceled)
-
-        val up2 = down2.up(50)
-        assertFalse(up2.isConsumed)
-
-        assertFalse(tapped)
-        assertFalse(released)
-    }
-
-    /**
-     * Detect the second tap as long press.
-     */
-    @Test
-    fun secondTapLongPress() = allGestures.executeInComposition {
-        down(5f, 5f).up()
-
-        assertTrue(pressed)
-        assertTrue(released)
-        assertFalse(canceled)
-        assertFalse(tapped)
-        assertFalse(doubleTapped)
-        assertFalse(longPressed)
-
-        pressed = false
-        released = false
-
-        val secondDown = down(5f, 5f, 50)
-
-        assertTrue(pressed)
-
-        delay(LongPressTimeoutMillis + 10)
-
-        assertTrue(tapped)
-        assertTrue(longPressed)
-        assertFalse(released)
-        assertFalse(canceled)
-
-        secondDown.up(500)
-        assertTrue(released)
-    }
-}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt
deleted file mode 100644
index 77c4a91..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/gestures/TransformGestureDetectorTest.kt
+++ /dev/null
@@ -1,352 +0,0 @@
-/*
- * Copyright 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT 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.gestures
-
-import androidx.compose.ui.geometry.Offset
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-
-@RunWith(Parameterized::class)
-class TransformGestureDetectorTest(val panZoomLock: Boolean) {
-    companion object {
-        @JvmStatic
-        @Parameterized.Parameters
-        fun parameters() = arrayOf(false, true)
-    }
-
-    private var centroid = Offset.Zero
-    private var panned = false
-    private var panAmount = Offset.Zero
-    private var rotated = false
-    private var rotateAmount = 0f
-    private var zoomed = false
-    private var zoomAmount = 1f
-
-    private val util = SuspendingGestureTestUtil {
-        detectTransformGestures(panZoomLock = panZoomLock) { c, pan, gestureZoom, gestureAngle ->
-            centroid = c
-            if (gestureAngle != 0f) {
-                rotated = true
-                rotateAmount += gestureAngle
-            }
-            if (gestureZoom != 1f) {
-                zoomed = true
-                zoomAmount *= gestureZoom
-            }
-            if (pan != Offset.Zero) {
-                panned = true
-                panAmount += pan
-            }
-        }
-    }
-
-    @Before
-    fun setup() {
-        panned = false
-        panAmount = Offset.Zero
-        rotated = false
-        rotateAmount = 0f
-        zoomed = false
-        zoomAmount = 1f
-    }
-
-    /**
-     * Single finger pan.
-     */
-    @Test
-    fun singleFingerPan() = util.executeInComposition {
-        val down = down(5f, 5f)
-        assertFalse(down.isConsumed)
-
-        assertFalse(panned)
-
-        val move1 = down.moveBy(Offset(12.7f, 12.7f))
-        assertFalse(move1.isConsumed)
-
-        assertFalse(panned)
-
-        val move2 = move1.moveBy(Offset(0.1f, 0.1f))
-        assertTrue(move2.isConsumed)
-
-        assertEquals(17.7f, centroid.x, 0.1f)
-        assertEquals(17.7f, centroid.y, 0.1f)
-        assertTrue(panned)
-        assertFalse(zoomed)
-        assertFalse(rotated)
-
-        assertTrue(panAmount.getDistance() < 1f)
-
-        panAmount = Offset.Zero
-        val move3 = move2.moveBy(Offset(1f, 0f))
-        assertTrue(move3.isConsumed)
-
-        assertEquals(Offset(1f, 0f), panAmount)
-
-        move3.up().also { assertFalse(it.isConsumed) }
-
-        assertFalse(rotated)
-        assertFalse(zoomed)
-    }
-
-    /**
-     * Multi-finger pan
-     */
-    @Test
-    fun multiFingerPanZoom() = util.executeInComposition {
-        val downA = down(5f, 5f)
-        assertFalse(downA.isConsumed)
-
-        val downB = down(25f, 25f)
-        assertFalse(downB.isConsumed)
-
-        assertFalse(panned)
-
-        val moveA1 = downA.moveBy(Offset(12.8f, 12.8f))
-        assertFalse(moveA1.isConsumed)
-
-        val moveB1 = downB.moveBy(Offset(12.8f, 12.8f))
-        // Now we've averaged enough movement
-        assertTrue(moveB1.isConsumed)
-
-        assertEquals((5f + 25f + 12.8f) / 2f, centroid.x, 0.1f)
-        assertEquals((5f + 25f + 12.8f) / 2f, centroid.y, 0.1f)
-        assertTrue(panned)
-        assertTrue(zoomed)
-        assertFalse(rotated)
-
-        assertEquals(6.4f, panAmount.x, 0.1f)
-        assertEquals(6.4f, panAmount.y, 0.1f)
-
-        moveA1.up()
-        moveB1.up()
-    }
-
-    /**
-     * 2-pointer zoom
-     */
-    @Test
-    fun zoom2Pointer() = util.executeInComposition {
-        val downA = down(5f, 5f)
-        assertFalse(downA.isConsumed)
-
-        val downB = down(25f, 5f)
-        assertFalse(downB.isConsumed)
-
-        val moveB1 = downB.moveBy(Offset(35.95f, 0f))
-        assertFalse(moveB1.isConsumed)
-
-        val moveB2 = moveB1.moveBy(Offset(0.1f, 0f))
-        assertTrue(moveB2.isConsumed)
-
-        assertTrue(panned)
-        assertTrue(zoomed)
-        assertFalse(rotated)
-
-        // both should be small movements
-        assertTrue(panAmount.getDistance() < 1f)
-        assertTrue(zoomAmount in 1f..1.1f)
-
-        zoomAmount = 1f
-        panAmount = Offset.Zero
-
-        val moveA1 = downA.moveBy(Offset(-1f, 0f))
-        assertTrue(moveA1.isConsumed)
-
-        val moveB3 = moveB2.moveBy(Offset(1f, 0f))
-        assertTrue(moveB3.isConsumed)
-
-        assertEquals(0f, panAmount.x, 0.01f)
-        assertEquals(0f, panAmount.y, 0.01f)
-
-        assertEquals(48f / 46f, zoomAmount, 0.01f)
-
-        moveA1.up()
-        moveB3.up()
-    }
-
-    /**
-     * 4-pointer zoom
-     */
-    @Test
-    fun zoom4Pointer() = util.executeInComposition {
-        val downA = down(0f, 50f)
-        // just get past the touch slop
-        val slop1 = downA.moveBy(Offset(-1000f, 0f))
-        val slop2 = slop1.moveBy(Offset(1000f, 0f))
-
-        panned = false
-        panAmount = Offset.Zero
-
-        val downB = down(100f, 50f)
-        val downC = down(50f, 0f)
-        val downD = down(50f, 100f)
-
-        val moveA = slop2.moveBy(Offset(-50f, 0f))
-        val moveB = downB.moveBy(Offset(50f, 0f))
-
-        assertTrue(zoomed)
-        assertTrue(panned)
-
-        assertEquals(0f, panAmount.x, 0.1f)
-        assertEquals(0f, panAmount.y, 0.1f)
-        assertEquals(1.5f, zoomAmount, 0.1f)
-
-        val moveC = downC.moveBy(Offset(0f, -50f))
-        val moveD = downD.moveBy(Offset(0f, 50f))
-
-        assertEquals(0f, panAmount.x, 0.1f)
-        assertEquals(0f, panAmount.y, 0.1f)
-        assertEquals(2f, zoomAmount, 0.1f)
-
-        moveA.up()
-        moveB.up()
-        moveC.up()
-        moveD.up()
-    }
-
-    /**
-     * 2 pointer rotation.
-     */
-    @Test
-    fun rotation2Pointer() = util.executeInComposition {
-        val downA = down(0f, 50f)
-        val downB = down(100f, 50f)
-        val moveA = downA.moveBy(Offset(50f, -50f))
-        val moveB = downB.moveBy(Offset(-50f, 50f))
-
-        // assume some of the above was touch slop
-        assertTrue(rotated)
-        rotateAmount = 0f
-        rotated = false
-        zoomAmount = 1f
-        panAmount = Offset.Zero
-
-        // now do the real rotation:
-        val moveA2 = moveA.moveBy(Offset(-50f, 50f))
-        val moveB2 = moveB.moveBy(Offset(50f, -50f))
-
-        moveA2.up()
-        moveB2.up()
-
-        assertTrue(rotated)
-        assertEquals(-90f, rotateAmount, 0.01f)
-        assertEquals(0f, panAmount.x, 0.1f)
-        assertEquals(0f, panAmount.y, 0.1f)
-        assertEquals(1f, zoomAmount, 0.1f)
-    }
-
-    /**
-     * 2 pointer rotation, with early panning.
-     */
-    @Test
-    fun rotation2PointerLock() = util.executeInComposition {
-        val downA = down(0f, 50f)
-        // just get past the touch slop with panning
-        val slop1 = downA.moveBy(Offset(-1000f, 0f))
-        val slop2 = slop1.moveBy(Offset(1000f, 0f))
-
-        val downB = down(100f, 50f)
-
-        // now do the rotation:
-        val moveA2 = slop2.moveBy(Offset(50f, -50f))
-        val moveB2 = downB.moveBy(Offset(-50f, 50f))
-
-        moveA2.up()
-        moveB2.up()
-
-        if (panZoomLock) {
-            assertFalse(rotated)
-        } else {
-            assertTrue(rotated)
-            assertEquals(90f, rotateAmount, 0.01f)
-        }
-        assertEquals(0f, panAmount.x, 0.1f)
-        assertEquals(0f, panAmount.y, 0.1f)
-        assertEquals(1f, zoomAmount, 0.1f)
-    }
-
-    /**
-     * Adding or removing a pointer won't change the current values
-     */
-    @Test
-    fun noChangeOnPointerDownUp() = util.executeInComposition {
-        val downA = down(0f, 50f)
-        val downB = down(100f, 50f)
-        val moveA = downA.moveBy(Offset(50f, -50f))
-        val moveB = downB.moveBy(Offset(-50f, 50f))
-
-        // now we've gotten past the touch slop
-        rotated = false
-        panned = false
-        zoomed = false
-
-        val downC = down(0f, 50f)
-
-        assertFalse(rotated)
-        assertFalse(panned)
-        assertFalse(zoomed)
-
-        val downD = down(100f, 50f)
-        assertFalse(rotated)
-        assertFalse(panned)
-        assertFalse(zoomed)
-
-        moveA.up()
-        moveB.up()
-        downC.up()
-        downD.up()
-
-        assertFalse(rotated)
-        assertFalse(panned)
-        assertFalse(zoomed)
-    }
-
-    /**
-     * Consuming position during touch slop will cancel the current gesture.
-     */
-    @Test
-    fun touchSlopCancel() = util.executeInComposition {
-        down(5f, 5f)
-            .moveBy(Offset(50f, 0f)) { consume() }
-            .up()
-
-        assertFalse(panned)
-        assertFalse(zoomed)
-        assertFalse(rotated)
-    }
-
-    /**
-     * Consuming position after touch slop will cancel the current gesture.
-     */
-    @Test
-    fun afterTouchSlopCancel() = util.executeInComposition {
-        down(5f, 5f)
-            .moveBy(Offset(50f, 0f))
-            .moveBy(Offset(50f, 0f)) { consume() }
-            .up()
-
-        assertTrue(panned)
-        assertFalse(zoomed)
-        assertFalse(rotated)
-        assertEquals(50f, panAmount.x, 0.1f)
-    }
-}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
deleted file mode 100644
index aa03db7..0000000
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text/PointerMoveDetectorTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.foundation.text
-
-import androidx.compose.foundation.gestures.SuspendingGestureTestUtil
-import androidx.compose.ui.geometry.Offset
-import com.google.common.truth.Correspondence
-import com.google.common.truth.IterableSubject
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
-
-@RunWith(JUnit4::class)
-class PointerMoveDetectorTest {
-    @Test
-    fun whenSimpleMovement_allMovesAreReported() {
-        val actualMoves = mutableListOf()
-        SuspendingGestureTestUtil {
-            detectMoves { actualMoves.add(it) }
-        }.executeInComposition {
-            down(5f, 5f)
-                .moveTo(4f, 4f)
-                .moveTo(3f, 3f)
-                .moveTo(2f, 2f)
-                .moveTo(1f, 1f)
-                .up()
-
-            assertThat(actualMoves).hasEqualOffsets(
-                listOf(
-                    Offset(4f, 4f),
-                    Offset(3f, 3f),
-                    Offset(2f, 2f),
-                    Offset(1f, 1f),
-                )
-            )
-        }
-    }
-
-    @Test
-    fun whenMultiplePointers_onlyUseFirst() {
-        val actualMoves = mutableListOf()
-        SuspendingGestureTestUtil {
-            detectMoves { actualMoves.add(it) }
-        }.executeInComposition {
-            var m1 = down(5f, 5f)
-            var m2 = down(6f, 6f)
-            m1 = m1.moveTo(4f, 4f)
-            m2 = m2.moveTo(7f, 7f)
-            m1 = m1.moveTo(3f, 3f)
-            m2 = m2.moveTo(8f, 8f)
-            m1 = m1.moveTo(2f, 2f)
-            m2 = m2.moveTo(9f, 9f)
-            m1.moveTo(1f, 1f)
-            m2.moveTo(10f, 10f)
-            m1.up()
-            m2.up()
-
-            assertThat(actualMoves).hasEqualOffsets(
-                listOf(
-                    Offset(4f, 4f),
-                    Offset(3f, 3f),
-                    Offset(2f, 2f),
-                    Offset(1f, 1f),
-                )
-            )
-        }
-    }
-
-    @Test
-    fun whenMultiplePointers_thenFirstReleases_handOffToNextPointer() {
-        val actualMoves = mutableListOf()
-        SuspendingGestureTestUtil {
-            detectMoves { actualMoves.add(it) }
-        }.executeInComposition {
-            var m1 = down(5f, 5f) // ignored because not a move
-            m1 = m1.moveTo(4f, 4f) // used
-            m1 = m1.moveTo(3f, 3f) // used
-            var m2 = down(4f, 4f) // ignored because still tracking m1
-            m1 = m1.moveTo(2f, 2f) // used
-            m2 = m2.moveTo(3f, 3f) // ignored because still tracking m1
-            m1.up() // ignored because not a move
-            m2.moveTo(2f, 2f) // ignored because equal to the previous used move
-            m2.moveTo(1f, 1f) // used
-            m2.up() // ignored because not a move
-
-            assertThat(actualMoves).hasEqualOffsets(
-                listOf(
-                    Offset(4f, 4f),
-                    Offset(3f, 3f),
-                    Offset(2f, 2f),
-                    Offset(1f, 1f),
-                )
-            )
-        }
-    }
-
-    private fun IterableSubject.hasEqualOffsets(expectedMoves: List) {
-        comparingElementsUsing(offsetCorrespondence)
-            .containsExactly(*expectedMoves.toTypedArray())
-            .inOrder()
-    }
-
-    private val offsetCorrespondence: Correspondence = Correspondence.from(
-        { o1, o2 -> o1!!.x == o2!!.x && o1.y == o2.y },
-        "has the offset of",
-    )
-}
diff --git a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
index f0abfb3..7d94bd5 100644
--- a/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
+++ b/compose/ui/ui/integration-tests/ui-demos/src/main/java/androidx/compose/ui/demos/gestures/EventTypesDemo.kt
@@ -42,7 +42,10 @@
 @Composable
 private fun TextItem(text: String, color: Color) {
     Row {
-        Box(Modifier.size(25.dp).background(color))
+        Box(
+            Modifier
+                .size(25.dp)
+                .background(color))
         Spacer(Modifier.width(5.dp))
         Text(text, fontSize = 20.sp)
     }
@@ -60,6 +63,7 @@
             PointerEventType.Enter -> Color.Green
             PointerEventType.Exit -> Color.Blue
             PointerEventType.Scroll -> Color(0xFF800080) // Purple
+            PointerEventType.Unknown -> Color.White
             else -> Color.Black
         }
         TextItem("$type $value", color)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
index 417ace5..4f5a9d6 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputDensityTest.kt
@@ -32,6 +32,8 @@
 import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.unit.Density
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
@@ -47,7 +49,7 @@
     @get:Rule
     val rule = createComposeRule()
 
-    val tag = "Tagged Layout"
+    private val tag = "Tagged Layout"
 
     @Test
     fun sendNotANumberDensityInPointerEvents() {
@@ -211,16 +213,36 @@
                             awaitPointerEvent()
                         }
                     }
-                })
+                }.testTag(tag)
+                )
             }
         }
 
+        // Because the pointer input coroutine scope is created lazily, that is, it won't be
+        // created/triggered until there is a event(tap), we must trigger a tap to instantiate the
+        // pointer input block of code.
+        rule.waitForIdle()
+        rule.onNodeWithTag(tag)
+            .performTouchInput {
+                down(Offset.Zero)
+                moveBy(Offset(1f, 1f))
+                up()
+            }
+
         rule.runOnIdle {
             assertThat(pointerInputDensities.size).isEqualTo(1)
             assertThat(pointerInputDensities.last()).isEqualTo(5f)
             density = 9f
         }
 
+        rule.waitForIdle()
+        rule.onNodeWithTag(tag)
+            .performTouchInput {
+                down(Offset.Zero)
+                moveBy(Offset(1f, 1f))
+                up()
+            }
+
         rule.runOnIdle {
             assertThat(pointerInputDensities.size).isEqualTo(2)
             assertThat(pointerInputDensities.last()).isEqualTo(9f)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt
new file mode 100644
index 0000000..887b17a
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/PointerInputViewConfigurationTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.compose.ui.input.pointer
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The block of code for a pointer input should be reset if the view configuration changes. This
+ * class tests all those key possibilities.
+ */
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class PointerInputViewConfigurationTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val tag = "Tagged Layout"
+
+    @Test
+    fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload1() {
+        compositionLocalViewConfigurationChangeRestartsPointerInput {
+            Modifier.pointerInput(Unit, block = it)
+        }
+    }
+
+    @Test
+    fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload2() {
+        compositionLocalViewConfigurationChangeRestartsPointerInput {
+            Modifier.pointerInput(Unit, Unit, block = it)
+        }
+    }
+
+    @Test
+    fun compositionLocalViewConfigurationChangeRestartsPointerInputOverload3() {
+        compositionLocalViewConfigurationChangeRestartsPointerInput {
+            Modifier.pointerInput(Unit, Unit, Unit, block = it)
+        }
+    }
+
+    private fun compositionLocalViewConfigurationChangeRestartsPointerInput(
+        pointerInput: (block: suspend PointerInputScope.() -> Unit) -> Modifier
+    ) {
+        var viewConfigurationTouchSlop by mutableStateOf(18f)
+
+        val pointerInputViewConfigurations = mutableListOf()
+        rule.setContent {
+            CompositionLocalProvider(
+                LocalViewConfiguration provides TestViewConfiguration(
+                    touchSlop = viewConfigurationTouchSlop
+                ),
+            ) {
+                Box(pointerInput {
+                    pointerInputViewConfigurations.add(viewConfigurationTouchSlop)
+                    awaitPointerEventScope {
+                        while (true) {
+                            awaitPointerEvent()
+                        }
+                    }
+                }.testTag(tag)
+                )
+            }
+        }
+
+        // Because the pointer input coroutine scope is created lazily, that is, it won't be
+        // created/triggered until there is a event(tap), we must trigger a tap to instantiate the
+        // pointer input block of code.
+        rule.waitForIdle()
+        rule.onNodeWithTag(tag)
+            .performTouchInput {
+                down(Offset.Zero)
+                moveBy(Offset(1f, 1f))
+                up()
+            }
+
+        rule.runOnIdle {
+            assertThat(pointerInputViewConfigurations.size).isEqualTo(1)
+            assertThat(pointerInputViewConfigurations.last()).isEqualTo(18f)
+            viewConfigurationTouchSlop = 20f
+        }
+
+        rule.waitForIdle()
+        rule.onNodeWithTag(tag)
+            .performTouchInput {
+                down(Offset.Zero)
+                moveBy(Offset(1f, 1f))
+                up()
+            }
+
+        rule.runOnIdle {
+            assertThat(pointerInputViewConfigurations.size).isEqualTo(2)
+            assertThat(pointerInputViewConfigurations.last()).isEqualTo(20f)
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
index d5b715f..bfda21fb 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilterTest.kt
@@ -16,37 +16,34 @@
 
 package androidx.compose.ui.input.pointer
 
-import androidx.activity.compose.setContent
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshots.Snapshot
-import androidx.compose.testutils.TestViewConfiguration
+import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.InspectableValue
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.platform.ValueElement
+import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
-import androidx.compose.ui.test.TestActivity
+import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.unit.IntSize
-import androidx.lifecycle.Lifecycle
-import androidx.test.core.app.ActivityScenario
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.test.filters.MediumTest
 import androidx.test.filters.SmallTest
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.CompletableDeferred
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.suspendCancellableCoroutine
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withTimeout
 import kotlinx.coroutines.yield
@@ -56,66 +53,86 @@
 import org.junit.Assert.fail
 import org.junit.Test
 import org.junit.runner.RunWith
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
+import org.junit.Rule
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
 @OptIn(ExperimentalCoroutinesApi::class)
 class SuspendingPointerInputFilterTest {
+    @get:Rule
+    val rule = createComposeRule()
+
     @After
     fun after() {
         // some tests may set this
         isDebugInspectorInfoEnabled = false
     }
 
-    private fun runTestUnconfined(test: suspend TestScope.() -> Unit) =
-        runTest(UnconfinedTestDispatcher()) {
-            test()
-        }
-
     @Test
-    fun testAwaitSingleEvent(): Unit = runTestUnconfined {
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
-
-        val result = CompletableDeferred()
-        launch {
-            with(filter) {
-                awaitPointerEventScope {
-                    result.complete(awaitPointerEvent())
-                }
-            }
-        }
-
+    @MediumTest
+    fun testAwaitSingleEvent() {
+        val latch = CountDownLatch(1)
         val emitter = PointerInputChangeEmitter()
         val expectedChange = emitter.nextChange(Offset(5f, 5f))
 
-        filter.onPointerEvent(
-            expectedChange.toPointerEvent(),
-            PointerEventPass.Main,
-            IntSize(10, 10)
-        )
+        // Used to manually trigger a PointerEvent created from our PointerInputChange.
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+        var returnedChange: PointerEvent? = null
 
-        val receivedEvent = withTimeout(200) {
-            result.await()
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+                awaitPointerEventScope {
+                    returnedChange = awaitPointerEvent()
+                    latch.countDown()
+                }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
         }
 
-        assertEquals(expectedChange, receivedEvent.firstChange)
+        rule.runOnIdle {
+            testSuspendPointerInputModifierNodeElement?.let {
+                it.pointerInputModifierNode?.onPointerEvent(
+                    expectedChange.toPointerEvent(),
+                    PointerEventPass.Main,
+                    IntSize(10, 10)
+                )
+            }
+        }
+
+        rule.runOnIdle {
+            assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
+            assertEquals(expectedChange, returnedChange?.firstChange)
+        }
     }
 
     @Test
-    fun testAwaitSeveralEvents(): Unit = runTestUnconfined {
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
+    @MediumTest
+    fun testAwaitSeveralEvents() {
+        val latch = CountDownLatch(3)
         val results = Channel(Channel.UNLIMITED)
-        launch {
-            with(filter) {
+
+        // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
                 awaitPointerEventScope {
                     repeat(3) {
                         results.trySend(awaitPointerEvent())
+                        latch.countDown()
                     }
                     results.close()
                 }
-            }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
         }
 
         val emitter = PointerInputChangeEmitter()
@@ -126,36 +143,62 @@
         )
 
         val bounds = IntSize(20, 20)
-        expected.forEach {
-            filter.onPointerEvent(it.toPointerEvent(), PointerEventPass.Main, bounds)
-        }
-        val received = withTimeout(200) {
-            results.receiveAsFlow()
-                .map { it.firstChange }
-                .toList()
+
+        rule.runOnIdle {
+            expected.forEach { pointerInputChange ->
+                testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerInputChange.toPointerEvent(),
+                        PointerEventPass.Main,
+                        bounds
+                    )
+                }
+            }
         }
 
-        assertEquals(expected, received)
+        rule.runOnIdle {
+            assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
+
+            runTest {
+                val received = withTimeout(200) {
+                    results.receiveAsFlow()
+                        .map { it.firstChange }
+                        .toList()
+                }
+                assertEquals(expected, received)
+            }
+        }
     }
 
     @Test
-    fun testSyntheticCancelEvent(): Unit = runTestUnconfined {
+    @MediumTest
+    fun testSyntheticCancelEvent() {
         var currentEventAtEnd: PointerEvent? = null
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
+        val latch = CountDownLatch(3)
         val results = Channel(Channel.UNLIMITED)
-        launch {
-            with(filter) {
+
+        // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
                 awaitPointerEventScope {
                     try {
                         repeat(3) {
                             results.trySend(awaitPointerEvent())
+                            latch.countDown()
                         }
                         results.close()
                     } finally {
                         currentEventAtEnd = currentEvent
                     }
                 }
-            }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
         }
 
         val bounds = IntSize(50, 50)
@@ -174,7 +217,9 @@
                     emitter2.nextChange(Offset(10f, 10f), down = false)
                 )
             ),
-            // Synthetic cancel should look like this;
+            // Synthetic cancel should look like this (Note: this specific event isn't ever
+            // triggered directly, it's just for reference so you know what onCancelPointerInput()
+            // triggers).
             // Both pointers are there, but only the with the pressed = true is changed to false,
             // and the down change is consumed.
             PointerEvent(
@@ -203,37 +248,79 @@
             )
         )
 
-        expectedEvents.take(expectedEvents.size - 1).forEach {
-            filter.onPointerEvent(it, PointerEventPass.Initial, bounds)
-            filter.onPointerEvent(it, PointerEventPass.Main, bounds)
-            filter.onPointerEvent(it, PointerEventPass.Final, bounds)
-        }
-        filter.onCancel()
+        rule.runOnIdle {
+            expectedEvents.take(expectedEvents.size - 1).forEach { pointerEvent ->
+                testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                    // Initial
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Initial,
+                        bounds
+                    )
 
-        val received = withTimeout(200) {
-            results.receiveAsFlow().toList()
+                    // Main
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Main,
+                        bounds
+                    )
+
+                    // Final
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Final,
+                        bounds
+                    )
+                }
+            }
+
+            // Triggers cancel event
+            testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                testerNodeElement.pointerInputModifierNode?.onCancelPointerInput()
+            }
         }
 
-        assertThat(expectedEvents).hasSize(received.size)
+        // Checks events triggered are the correct ones
+        rule.runOnIdle {
+            assertTrue("Waiting for relaunch timed out", latch.await(200, TimeUnit.MILLISECONDS))
 
-        expectedEvents.forEachIndexed { index, expectedEvent ->
-            val actualEvent = received[index]
-            PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+            runTest {
+                val received = withTimeout(200) {
+                    results.receiveAsFlow().toList()
+                }
+
+                assertThat(expectedEvents).hasSize(received.size)
+
+                expectedEvents.forEachIndexed { index, expectedEvent ->
+                    val actualEvent = received[index]
+                    PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+                }
+                assertThat(currentEventAtEnd).isNotNull()
+                PointerEventSubject.assertThat(currentEventAtEnd!!)
+                    .isStructurallyEqualTo(expectedEvents.last())
+            }
         }
-        assertThat(currentEventAtEnd).isNotNull()
-        PointerEventSubject.assertThat(currentEventAtEnd!!)
-            .isStructurallyEqualTo(expectedEvents.last())
     }
 
     @Test
-    fun testNoSyntheticCancelEventWhenPressIsFalse(): Unit = runTestUnconfined {
+    @LargeTest
+    fun testNoSyntheticCancelEventWhenPressIsFalse() {
         var currentEventAtEnd: PointerEvent? = null
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
         val results = Channel(Channel.UNLIMITED)
-        launch {
-            with(filter) {
+
+        // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
                 awaitPointerEventScope {
                     try {
+                        // NOTE: This will never trigger 3 times. There are only two events
+                        // triggered followed by a onCancelPointerInput() call which doesn't trigger
+                        // an event because the previous event has down (press) set to false, so we
+                        // will always get an exception thrown with the last repeat's timeout
+                        // (we expect this).
                         repeat(3) {
                             withTimeout(200) {
                                 results.trySend(awaitPointerEvent())
@@ -244,154 +331,383 @@
                         results.close()
                     }
                 }
-            }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
         }
 
         val bounds = IntSize(50, 50)
         val emitter1 = PointerInputChangeEmitter(0)
         val emitter2 = PointerInputChangeEmitter(1)
-        val expectedEvents = listOf(
+        val twoExpectedEvents = listOf(
             PointerEvent(
                 listOf(
                     emitter1.nextChange(Offset(5f, 5f)),
                     emitter2.nextChange(Offset(10f, 10f))
                 )
             ),
+            // Pointer event changes don't have any pressed pointers!
             PointerEvent(
                 listOf(
                     emitter1.nextChange(Offset(6f, 6f), down = false),
                     emitter2.nextChange(Offset(10f, 10f), down = false)
                 )
             )
-            // Unlike when a pointer is down, there is no cancel event sent
-            // when there aren't any pressed pointers. There's no event stream to cancel.
         )
 
-        expectedEvents.forEach {
-            filter.onPointerEvent(it, PointerEventPass.Initial, bounds)
-            filter.onPointerEvent(it, PointerEventPass.Main, bounds)
-            filter.onPointerEvent(it, PointerEventPass.Final, bounds)
-        }
-        filter.onCancel()
+        rule.runOnIdle {
+            twoExpectedEvents.forEach { pointerEvent ->
+                testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                    // Initial
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Initial,
+                        bounds
+                    )
 
-        withTimeout(400) {
-            while (!results.isClosedForSend) {
-                yield()
-            }
-        }
+                    // Main
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Main,
+                        bounds
+                    )
 
-        val received = results.receiveAsFlow().toList()
-
-        assertThat(received).hasSize(expectedEvents.size)
-
-        expectedEvents.forEachIndexed { index, expectedEvent ->
-            val actualEvent = received[index]
-            PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
-        }
-        assertThat(currentEventAtEnd).isNotNull()
-        PointerEventSubject.assertThat(currentEventAtEnd!!)
-            .isStructurallyEqualTo(expectedEvents.last())
-    }
-
-    @Test
-    fun testCancelledHandlerBlock() = runTestUnconfined {
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
-        val counter = TestCounter()
-        val handler = launch {
-            with(filter) {
-                try {
-                    awaitPointerEventScope {
-                        try {
-                            counter.expect(1, "about to call awaitPointerEvent")
-                            awaitPointerEvent()
-                            fail("awaitPointerEvent returned; should have thrown for cancel")
-                        } finally {
-                            counter.expect(3, "inner finally block running")
-                        }
-                    }
-                } finally {
-                    counter.expect(4, "outer finally block running; inner finally should have run")
-                }
-            }
-        }
-
-        counter.expect(2, "before cancelling handler; awaitPointerEvent should be suspended")
-        handler.cancel()
-        counter.expect(5, "after cancelling; finally blocks should have run")
-    }
-
-    @Test
-    fun testInspectorValue() = runBlocking {
-        isDebugInspectorInfoEnabled = true
-        val block: suspend PointerInputScope.() -> Unit = {}
-        val modifier = Modifier.pointerInput(Unit, block) as InspectableValue
-
-        assertThat(modifier.nameFallback).isEqualTo("pointerInput")
-        assertThat(modifier.valueOverride).isNull()
-        assertThat(modifier.inspectableElements.asIterable()).containsExactly(
-            ValueElement("key1", Unit),
-            ValueElement("block", block)
-        )
-    }
-
-    @Test
-    @LargeTest
-    fun testRestartPointerInput() = runBlocking {
-        var toAdd by mutableStateOf("initial")
-        val result = mutableListOf()
-        val latch = CountDownLatch(2)
-        ActivityScenario.launch(TestActivity::class.java).use { scenario ->
-            scenario.moveToState(Lifecycle.State.CREATED)
-            scenario.onActivity {
-                it.setContent {
-                    // Read the value in composition to change the lambda capture below
-                    val toCapture = toAdd
-                    Box(
-                        Modifier.pointerInput(toCapture) {
-                            result += toCapture
-                            latch.countDown()
-                            suspendCancellableCoroutine {}
-                        }
+                    // Final
+                    testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                        pointerEvent,
+                        PointerEventPass.Final,
+                        bounds
                     )
                 }
             }
-            scenario.moveToState(Lifecycle.State.STARTED)
-            Snapshot.withMutableSnapshot {
-                toAdd = "secondary"
+
+            // Manually triggers cancel event.
+            // Note: This will not trigger an event in the customPointerInput block because the
+            // previous events don't have any pressed pointers.
+            testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                testerNodeElement.pointerInputModifierNode?.onCancelPointerInput()
             }
-            assertTrue("waiting for relaunch timed out", latch.await(3, TimeUnit.SECONDS))
-            assertEquals(
-                listOf("initial", "secondary"),
-                result
+        }
+
+        rule.mainClock.advanceTimeBy(1000)
+
+        rule.runOnIdle {
+            runTest {
+                withTimeout(400) {
+                    while (!results.isClosedForSend) {
+                        yield()
+                    }
+                }
+
+                val received = results.receiveAsFlow().toList()
+
+                assertThat(received).hasSize(twoExpectedEvents.size)
+
+                twoExpectedEvents.forEachIndexed { index, expectedEvent ->
+                    val actualEvent = received[index]
+                    PointerEventSubject.assertThat(actualEvent).isStructurallyEqualTo(expectedEvent)
+                }
+                assertThat(currentEventAtEnd).isNotNull()
+                PointerEventSubject.assertThat(currentEventAtEnd!!)
+                    .isStructurallyEqualTo(twoExpectedEvents.last())
+            }
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun testCancelledHandlerBlock() {
+        val counter = TestCounter()
+
+        // Used to manually trigger a PointerEvent(s) created from our PointerInputChange(s).
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+                try {
+                    awaitPointerEventScope {
+                        try {
+                            counter.expect(3, "about to call awaitPointerEvent")
+
+                            // With only one event triggered, this will stay stuck in the repeat
+                            // block until the Job is cancelled via
+                            // SuspendPointerInputModifierNode.resetHandling()
+                            repeat(2) {
+                                awaitPointerEvent()
+                                counter.expect(
+                                    4,
+                                    "One and only pointer event triggered to create Job."
+                                )
+                            }
+
+                            fail("awaitPointerEvent returned; should have thrown for cancel")
+                        } finally {
+                            counter.expect(6, "inner finally block running")
+                        }
+                    }
+                } finally {
+                    counter.expect(7, "outer finally block running; inner " +
+                        "finally should have run")
+                }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
+        }
+
+        val emitter = PointerInputChangeEmitter()
+        val singleEvent = emitter.nextChange(Offset(5f, 5f))
+        val singleEventBounds = IntSize(20, 20)
+
+        rule.runOnIdle {
+            counter.expect(
+                1,
+                "Job to handle pointer input not created yet; awaitPointerEvent should " +
+                    "be suspended"
+            )
+
+            testSuspendPointerInputModifierNodeElement?.let { testerNodeElement ->
+                counter.expect(
+                    2,
+                    "Trigger pointer input event to create Job for handing handle pointer" +
+                        " input (done lazily in SuspendPointerInputModifierNode)."
+                )
+
+                testerNodeElement.pointerInputModifierNode?.onPointerEvent(
+                    singleEvent.toPointerEvent(),
+                    PointerEventPass.Main,
+                    singleEventBounds
+                )
+            }
+
+            counter.expect(5, "before cancelling handler; awaitPointerEvent " +
+                "should be suspended")
+
+            // Cancels Job that manages pointer input events in SuspendPointerInputModifierNode.
+            testSuspendPointerInputModifierNodeElement?.resetsPointerInputBlockHandler()
+            counter.expect(8, "after cancelling; finally blocks should have run")
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun testInspectorValue() {
+        isDebugInspectorInfoEnabled = true
+
+        rule.setContent {
+            val block: suspend PointerInputScope.() -> Unit = {}
+            val modifier =
+                Modifier.pointerInput(Unit, block) as SuspendPointerInputModifierNodeElement
+
+            assertThat(modifier.nameFallback).isEqualTo("pointerInput")
+            assertThat(modifier.valueOverride).isNull()
+            assertThat(modifier.inspectableElements.asIterable()).containsExactly(
+                ValueElement("key1", Unit),
+                ValueElement("key2", null),
+                ValueElement("keys", null),
+                ValueElement("block", block)
             )
         }
     }
 
-    @Test(expected = PointerEventTimeoutCancellationException::class)
-    fun testWithTimeout() = runTestUnconfined {
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
-        filter.coroutineScope = this
-        with(filter) {
-            awaitPointerEventScope {
-                withTimeout(10) {
-                    awaitPointerEvent()
-                }
+    @Test
+    @MediumTest
+    fun testRestartPointerInputWithTouchEvent() {
+        val emitter = PointerInputChangeEmitter()
+        val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+        // Used to manually trigger a PointerEvent created from our PointerInputChange.
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        var forceRecompositionCount by mutableStateOf(0)
+        var compositionCount = 0
+        var pointerInputBlockExecutionCount = 0
+
+        rule.setContent {
+            // Read the value in composition to change the lambda capture below
+            val toCapture = forceRecompositionCount
+            compositionCount++
+
+            testSuspendPointerInputModifierNodeElement =
+                Modifier.customTestingPointerInput(toCapture) {
+                    // pointerInput now lazily executes this block of code meaning it won't be
+                    // executed until an actual event happens.
+                    pointerInputBlockExecutionCount++
+                    suspendCancellableCoroutine {}
+                } as TestSuspendPointerInputModifierNodeElement
+            Box(modifier = testSuspendPointerInputModifierNodeElement!!)
+        }
+
+        forceRecompositionCount = 1
+
+        rule.runOnIdle {
+            // Triggers first and only event (and launches coroutine).
+            // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+            // will not be launched until the first event is triggered which is what we do here.
+            testSuspendPointerInputModifierNodeElement?.let {
+                it.pointerInputModifierNode?.onPointerEvent(
+                    expectedChange.toPointerEvent(),
+                    PointerEventPass.Main,
+                    IntSize(5, 5)
+                )
             }
         }
+
+        rule.runOnIdle {
+            assertEquals(compositionCount, 2)
+            // One pointer input event, should have triggered one execution.
+            assertEquals(pointerInputBlockExecutionCount, 1)
+        }
     }
 
     @Test
-    fun testWithTimeoutOrNull() = runTestUnconfined {
-        val filter = SuspendingPointerInputFilter(TestViewConfiguration())
-        filter.coroutineScope = this
-        val result: PointerEvent? = with(filter) {
-            awaitPointerEventScope {
-                withTimeoutOrNull(10) {
-                    awaitPointerEvent()
+    @MediumTest
+    fun testRestartPointerInputWithNoTouchEvents() {
+        var forceRecompositionCount by mutableStateOf(0)
+        var compositionCount = 0
+        var pointerInputBlockExecutionCount = 0
+
+        rule.setContent {
+            // Read the value in composition to change the lambda capture below
+            val toCapture = forceRecompositionCount
+            compositionCount++
+            Box(
+                Modifier.pointerInput(toCapture) {
+                    // pointerInput now lazily executes this block of code meaning it won't be
+                    // executed until an actual event happens.
+                    pointerInputBlockExecutionCount++
+                    suspendCancellableCoroutine {}
                 }
+            )
+        }
+
+        forceRecompositionCount = 1
+
+        rule.runOnIdle {
+            assertEquals(compositionCount, 2)
+            // No pointer input events, no block executions.
+            assertEquals(pointerInputBlockExecutionCount, 0)
+        }
+    }
+
+    @Test
+    @LargeTest
+    fun testWithTimeout() {
+        val latch = CountDownLatch(1)
+        val emitter = PointerInputChangeEmitter()
+        val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+        // Used to manually trigger a PointerEvent created from our PointerInputChange.
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+                awaitPointerEventScope {
+                    try {
+                        // Handles first event (needed to trigger the creation of the coroutine
+                        // since it is lazily created).
+                        awaitPointerEvent()
+
+                        // Times out waiting for second event (no second event is triggered in this
+                        // test).
+                        withTimeout(10) {
+                            awaitPointerEvent()
+                        }
+                    } catch (exception: Exception) {
+                        assertThat(exception)
+                            .isInstanceOf(PointerEventTimeoutCancellationException::class.java)
+                        latch.countDown()
+                    }
+                }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(modifier = testSuspendPointerInputModifierNodeElement!!)
+        }
+
+        rule.runOnIdle {
+            // Triggers first event (and launches coroutine).
+            // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+            // will not be launched until the first event is triggered which is what we do here.
+            testSuspendPointerInputModifierNodeElement?.let {
+                it.pointerInputModifierNode?.onPointerEvent(
+                    expectedChange.toPointerEvent(),
+                    PointerEventPass.Main,
+                    IntSize(5, 5)
+                )
             }
         }
-        assertThat(result).isNull()
+
+        rule.mainClock.advanceTimeBy(1000)
+
+        rule.runOnIdle {
+            assertTrue(latch.await(2, TimeUnit.SECONDS))
+        }
+    }
+
+    @Test
+    @LargeTest
+    fun testWithTimeoutOrNull() {
+        val emitter = PointerInputChangeEmitter()
+        val expectedChange = emitter.nextChange(Offset(5f, 5f))
+
+        // Sets an empty default (if not updated to null after call (expected), it will fail).
+        var resultOfTimeoutOrNull: PointerEvent? = PointerEvent(listOf())
+
+        // Used to manually trigger a PointerEvent created from our PointerInputChange.
+        var testSuspendPointerInputModifierNodeElement:
+            TestSuspendPointerInputModifierNodeElement? = null
+
+        rule.setContent {
+            testSuspendPointerInputModifierNodeElement = Modifier.customTestingPointerInput(Unit) {
+                awaitPointerEventScope {
+                    try {
+                        // Handles first event (needed to trigger the creation of the coroutine
+                        // since it is lazily created).
+                        awaitPointerEvent()
+
+                        // Times out waiting for second event (no second event is triggered in this
+                        // test).
+                        resultOfTimeoutOrNull = withTimeoutOrNull(10) {
+                            awaitPointerEvent()
+                        }
+                    } catch (exception: Exception) {
+                        // An exception should not be raised in this test, but, just in case one is,
+                        // we want to verify it isn't the one withTimeout will usually raise.
+                        assertThat(exception)
+                            .isNotInstanceOf(PointerEventTimeoutCancellationException::class.java)
+                    }
+                }
+            } as TestSuspendPointerInputModifierNodeElement
+
+            Box(
+                modifier = testSuspendPointerInputModifierNodeElement!!
+            )
+        }
+
+        rule.runOnIdle {
+            // Triggers first event (and launches coroutine).
+            // Note: SuspendPointerInputModifierNode actually launches its coroutine lazily, so it
+            // will not be launched until the first event is triggered which is what we do here.
+            testSuspendPointerInputModifierNodeElement?.let {
+                it.pointerInputModifierNode?.onPointerEvent(
+                    expectedChange.toPointerEvent(),
+                    PointerEventPass.Main,
+                    IntSize(5, 5)
+                )
+            }
+        }
+
+        rule.mainClock.advanceTimeBy(1000)
+
+        rule.runOnIdle {
+            assertThat(resultOfTimeoutOrNull).isNull()
+        }
     }
 }
 
@@ -438,3 +754,76 @@
         count = expected
     }
 }
+
+// Customized version of [Modifier.pointerInput] that uses the customized version of the
+// [SuspendPointerInputModifierNodeElement] class below (it allows us to manually trigger
+// [PointerEvent] events.
+internal fun Modifier.customTestingPointerInput(
+    key1: Any?,
+    block: suspend PointerInputScope.() -> Unit
+): Modifier = this then TestSuspendPointerInputModifierNodeElement(
+    key1 = key1,
+    block = block
+)
+
+// Matches [SuspendPointerInputModifierNodeElement] implementation but maintains a reference to a
+// [SuspendPointerInputModifierNode], so we can manually trigger [PointerEvent] events.
+@OptIn(ExperimentalComposeUiApi::class)
+internal class TestSuspendPointerInputModifierNodeElement(
+    val key1: Any? = null,
+    val key2: Any? = null,
+    val keys: Array? = null,
+    val block: suspend PointerInputScope.() -> Unit
+) : ModifierNodeElement() {
+    private var suspendPointerInputModifierNode: SuspendPointerInputModifierNode? = null
+    var pointerInputModifierNode: PointerInputModifierNode? = null
+
+    override fun InspectorInfo.inspectableProperties() {
+        debugInspectorInfo {
+            name = "pointerInput"
+            properties["key1"] = key1
+            properties["key2"] = key2
+            properties["keys"] = keys
+            properties["block"] = block
+        }
+    }
+
+    override fun create(): SuspendPointerInputModifierNode {
+        suspendPointerInputModifierNode = SuspendPointerInputModifierNode(block)
+        pointerInputModifierNode = suspendPointerInputModifierNode
+        return suspendPointerInputModifierNode as SuspendPointerInputModifierNode
+    }
+
+    override fun update(node: SuspendPointerInputModifierNode): SuspendPointerInputModifierNode {
+        node.block = block
+        suspendPointerInputModifierNode = node
+        pointerInputModifierNode = suspendPointerInputModifierNode
+        return suspendPointerInputModifierNode as SuspendPointerInputModifierNode
+    }
+
+    // Cancels Job that manages pointer input events in SuspendPointerInputModifierNode.
+    fun resetsPointerInputBlockHandler() {
+        suspendPointerInputModifierNode?.resetBlock()
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SuspendPointerInputModifierNodeElement) return false
+        if (key1 != other.key1) return false
+        if (key2 != other.key2) return false
+        if (keys != null) {
+            if (other.keys == null) return false
+            if (!keys.contentEquals(other.keys)) return false
+        } else if (other.keys != null) return false
+        if (block != other.block) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = key1?.hashCode() ?: 0
+        result = 31 * result + (key2?.hashCode() ?: 0)
+        result = 31 * result + (keys?.contentHashCode() ?: 0)
+        result = 31 * result + block.hashCode()
+        return result
+    }
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
index 3e001e7..b7b614a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/Modifier.kt
@@ -169,7 +169,6 @@
             private set
 
         private var scope: CoroutineScope? = null
-        // CoroutineScope(baseContext + Job(parent = baseContext[Job]))
         val coroutineScope: CoroutineScope
             get() = scope ?: CoroutineScope(
                 requireOwner().coroutineContext +
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
index e238fc0..3c7f48b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/SuspendingPointerInputFilter.kt
@@ -16,18 +16,13 @@
 
 package androidx.compose.ui.input.pointer
 
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collection.mutableVectorOf
-import androidx.compose.runtime.remember
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
 import androidx.compose.ui.fastMapNotNull
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
 import androidx.compose.ui.platform.ViewConfiguration
-import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.platform.synchronized
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
@@ -43,13 +38,16 @@
 import kotlin.math.max
 import kotlinx.coroutines.CancellableContinuation
 import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.suspendCancellableCoroutine
 import androidx.compose.ui.internal.JvmDefaultWithCompatibility
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.PointerInputModifierNode
+import androidx.compose.ui.node.requireLayoutNode
+import androidx.compose.ui.platform.InspectorInfo
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
 
 /**
  * Receiver scope for awaiting pointer events in a call to
@@ -229,22 +227,10 @@
 fun Modifier.pointerInput(
     key1: Any?,
     block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
-    inspectorInfo = debugInspectorInfo {
-        name = "pointerInput"
-        properties["key1"] = key1
-        properties["block"] = block
-    }
-) {
-    val density = LocalDensity.current
-    val viewConfiguration = LocalViewConfiguration.current
-    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
-        LaunchedEffect(filter, key1) {
-            filter.coroutineScope = this
-            filter.block()
-        }
-    }
-}
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+    key1 = key1,
+    block = block
+)
 
 /**
  * Create a modifier for processing pointer input within the region of the modified element.
@@ -276,23 +262,11 @@
     key1: Any?,
     key2: Any?,
     block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
-    inspectorInfo = debugInspectorInfo {
-        name = "pointerInput"
-        properties["key1"] = key1
-        properties["key2"] = key2
-        properties["block"] = block
-    }
-) {
-    val density = LocalDensity.current
-    val viewConfiguration = LocalViewConfiguration.current
-    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
-        LaunchedEffect(filter, key1, key2) {
-            filter.coroutineScope = this
-            filter.block()
-        }
-    }
-}
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+    key1 = key1,
+    key2 = key2,
+    block = block
+)
 
 /**
  * Create a modifier for processing pointer input within the region of the modified element.
@@ -322,20 +296,54 @@
 fun Modifier.pointerInput(
     vararg keys: Any?,
     block: suspend PointerInputScope.() -> Unit
-): Modifier = composed(
-    inspectorInfo = debugInspectorInfo {
+): Modifier = this then SuspendPointerInputModifierNodeElement(
+    keys = keys,
+    block = block
+)
+
+@OptIn(ExperimentalComposeUiApi::class)
+internal class SuspendPointerInputModifierNodeElement(
+    val key1: Any? = null,
+    val key2: Any? = null,
+    val keys: Array? = null,
+    val block: suspend PointerInputScope.() -> Unit
+) : ModifierNodeElement() {
+    override fun InspectorInfo.inspectableProperties() {
         name = "pointerInput"
+        properties["key1"] = key1
+        properties["key2"] = key2
         properties["keys"] = keys
         properties["block"] = block
     }
-) {
-    val density = LocalDensity.current
-    val viewConfiguration = LocalViewConfiguration.current
-    remember(density) { SuspendingPointerInputFilter(viewConfiguration, density) }.also { filter ->
-        LaunchedEffect(filter, *keys) {
-            filter.coroutineScope = this
-            filter.block()
-        }
+
+    override fun create(): SuspendPointerInputModifierNode {
+        return SuspendPointerInputModifierNode(block)
+    }
+
+    override fun update(node: SuspendPointerInputModifierNode): SuspendPointerInputModifierNode {
+        node.block = block
+        return node
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is SuspendPointerInputModifierNodeElement) return false
+
+        if (key1 != other.key1) return false
+        if (key2 != other.key2) return false
+        if (keys != null) {
+            if (other.keys == null) return false
+            if (!keys.contentEquals(other.keys)) return false
+        } else if (other.keys != null) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = key1?.hashCode() ?: 0
+        result = 31 * result + (key2?.hashCode() ?: 0)
+        result = 31 * result + (keys?.contentHashCode() ?: 0)
+        return result
     }
 }
 
@@ -343,29 +351,44 @@
 
 /**
  * Implementation notes:
- * This class does a lot of lifting. It is both a [PointerInputModifier] and that modifier's
- * own [pointerInputFilter]. It is returned by way of a [Modifier.composed] from
- * the [Modifier.pointerInput] builder and is always 1-1 with an instance of application to
- * a LayoutNode.
+ * This class does a lot of lifting. [PointerInputModifierNode] receives, interprets, and, consumes
+ * [PointerInputChange]s while the state (and the coroutineScope used to execute [block]) is
+ * retained in [Modifier.Node].
  *
- * [SuspendingPointerInputFilter] implements the [PointerInputScope] used to offer the
- * [Modifier.pointerInput] DSL and carries the [Density] from [LocalDensity] at the point of
- * the modifier's materialization. Even if this value were returned to the [PointerInputFilter]
- * callbacks, we would still need the value at composition time in order for [Modifier.pointerInput]
- * to begin its internal [LaunchedEffect] for the provided code block.
+ * [SuspendPointerInputModifierNode] implements the [PointerInputScope] used to offer the
+ * [Modifier.pointerInput] DSL and provides the [Density] from [LocalDensity] lazily from the
+ * layout node when it is needed.
+ *
+ * Note: The coroutine that executes the passed block for listening to events is launched lazily
+ * when the first event is fired (making it more efficient) and is cancelled via resetHandling()
+ * when
  */
-// TODO: Suppressing deprecation for synchronized; need to move to atomicfu wrapper
-@Suppress("DEPRECATION_ERROR")
-internal class SuspendingPointerInputFilter(
-    override val viewConfiguration: ViewConfiguration,
-    density: Density = Density(1f)
-) : PointerInputFilter(),
-    PointerInputModifier,
-    PointerInputScope,
-    Density by density {
+@OptIn(ExperimentalComposeUiApi::class)
+internal class SuspendPointerInputModifierNode(
+    block: suspend PointerInputScope.() -> Unit
+) : Modifier.Node(), PointerInputModifierNode, PointerInputScope, Density {
 
-    override val pointerInputFilter: PointerInputFilter
-        get() = this
+    var block = block
+        set(value) {
+            resetBlock()
+            field = value
+        }
+
+    override val density: Float
+        get() = requireLayoutNode().density.density
+
+    override val fontScale: Float
+        get() = requireLayoutNode().density.fontScale
+
+    override val viewConfiguration
+        get() = requireLayoutNode().viewConfiguration
+
+    override val size: IntSize
+        get() = boundsSize
+
+    // The code block passed in as a parameter to handle pointer input events is now executed lazily
+    // when the first event fires. This job indicates that pointer input handler job is running.
+    private var pointerInputJob: Job? = null
 
     private var currentEvent: PointerEvent = EmptyPointerEvent
 
@@ -373,7 +396,8 @@
      * Actively registered input handlers from currently ongoing calls to [awaitPointerEventScope].
      * Must use `synchronized(pointerHandlers)` to access.
      */
-    private val pointerHandlers = mutableVectorOf>()
+    private val pointerHandlers =
+        mutableVectorOf>()
 
     /**
      * Scratch list for dispatching to handlers for a particular phase.
@@ -381,7 +405,8 @@
      * resumed continuations may add/remove handlers without affecting the current dispatch pass.
      * Must only access on the UI thread.
      */
-    private val dispatchingPointerHandlers = mutableVectorOf>()
+    private val dispatchingPointerHandlers =
+        mutableVectorOf>()
 
     /**
      * The last pointer event we saw where at least one pointer was currently down; null otherwise.
@@ -398,12 +423,6 @@
      */
     private var boundsSize: IntSize = IntSize.Zero
 
-    /**
-     * This will be changed immediately on launching, but I always want it to be non-null.
-     */
-    @OptIn(DelicateCoroutinesApi::class)
-    var coroutineScope: CoroutineScope = GlobalScope
-
     override val extendedTouchPadding: Size
         get() {
             val minimumTouchTargetSize = viewConfiguration.minimumTouchTargetSize.toSize()
@@ -415,6 +434,27 @@
 
     override var interceptOutOfBoundsChildEvents: Boolean = false
 
+    override fun onDetach() {
+        resetBlock()
+        super.onDetach()
+    }
+
+    /**
+     * This cancels the existing coroutine and essentially resets the block's execution. Note, the
+     * block still executes lazily, meaning nothing will be done until a new event comes in.
+     * More details: This is triggered from a LayoutNode if the Density or ViewConfiguration change
+     * (in an older implementation using composed, these values were used as keys so it would reset
+     * everything when either change, we do that manually now through this function). It is also
+     * used for testing.
+     */
+    fun resetBlock() {
+        val localJob = pointerInputJob
+        if (localJob != null) {
+            localJob.cancel(CancellationException())
+            pointerInputJob = null
+        }
+    }
+
     /**
      * Snapshot the current [pointerHandlers] and run [block] on each one.
      * May not be called reentrant or concurrent with itself.
@@ -426,7 +466,7 @@
      */
     private inline fun forEachCurrentPointerHandler(
         pass: PointerEventPass,
-        block: (PointerEventHandlerCoroutine<*>) -> Unit
+        block: (SuspendPointerInputModifierNode.PointerEventHandlerCoroutine<*>) -> Unit
     ) {
         // Copy handlers to avoid mutating the collection during dispatch
         synchronized(pointerHandlers) {
@@ -436,6 +476,7 @@
             when (pass) {
                 PointerEventPass.Initial, PointerEventPass.Final ->
                     dispatchingPointerHandlers.forEach(block)
+
                 PointerEventPass.Main ->
                     dispatchingPointerHandlers.forEachReversed(block)
             }
@@ -466,6 +507,13 @@
         if (pass == PointerEventPass.Initial) {
             currentEvent = pointerEvent
         }
+
+        // Coroutine lazily launches when first event comes in.
+        if (pointerInputJob == null) {
+            // 'start = CoroutineStart.UNDISPATCHED' required so handler doesn't miss first event.
+            pointerInputJob = coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { block() }
+        }
+
         dispatchPointerEvent(pointerEvent, pass)
 
         lastPointerEvent = pointerEvent.takeIf { event ->
@@ -473,8 +521,7 @@
         }
     }
 
-    @OptIn(ExperimentalComposeUiApi::class)
-    override fun onCancel() {
+    override fun onCancelPointerInput() {
         // Synthesize a cancel event for whatever state we previously saw, if one is applicable.
         // A cancel event is one where all previously down pointers are now up, the change in
         // down-ness is consumed. Any pointers that were previously hovering are left unchanged.
@@ -506,6 +553,9 @@
         dispatchPointerEvent(cancelEvent, PointerEventPass.Final)
 
         lastPointerEvent = null
+
+        // Cancels existing coroutine (Job) handling events.
+        resetBlock()
     }
 
     override suspend fun  awaitPointerEventScope(
@@ -546,18 +596,18 @@
      */
     private inner class PointerEventHandlerCoroutine(
         private val completion: Continuation,
-    ) : AwaitPointerEventScope, Density by this@SuspendingPointerInputFilter, Continuation {
+    ) : AwaitPointerEventScope, Density by this@SuspendPointerInputModifierNode, Continuation {
         private var pointerAwaiter: CancellableContinuation? = null
         private var awaitPass: PointerEventPass = PointerEventPass.Main
 
         override val currentEvent: PointerEvent
-            get() = [email protected]
+            get() = [email protected]
         override val size: IntSize
-            get() = [email protected]
+            get() = [email protected]
         override val viewConfiguration: ViewConfiguration
-            get() = [email protected]
+            get() = [email protected]
         override val extendedTouchPadding: Size
-            get() = [email protected]
+            get() = [email protected]
 
         fun offerPointerEvent(event: PointerEvent, pass: PointerEventPass) {
             if (pass == awaitPass) {
@@ -612,6 +662,7 @@
                     PointerEventTimeoutCancellationException(timeMillis)
                 )
             }
+
             val job = coroutineScope.launch {
                 // Delay twice because the timeout continuation needs to be lower-priority than
                 // input events, not treated fairly in FIFO order. The second
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index a7a67f3..f07ffc6 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -47,6 +47,7 @@
 import androidx.compose.ui.node.Nodes.FocusEvent
 import androidx.compose.ui.node.Nodes.FocusProperties
 import androidx.compose.ui.node.Nodes.FocusTarget
+import androidx.compose.ui.node.Nodes.SuspendPointerInput
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.LocalViewConfiguration
@@ -661,6 +662,8 @@
             if (field != value) {
                 field = value
                 onDensityOrLayoutDirectionChanged()
+
+                invalidatePointerInputModifiers()
             }
         }
 
@@ -676,6 +679,13 @@
         }
 
     override var viewConfiguration: ViewConfiguration = DummyViewConfiguration
+        set(value) {
+            if (field != value) {
+                field = value
+                invalidatePointerInputModifiers()
+            }
+        }
+
     override var compositionLocalMap = CompositionLocalMap.Empty
         set(value) {
             field = value
@@ -703,6 +713,14 @@
         invalidateLayers()
     }
 
+    // The pointer input's code block (for handling incoming events) needs to be reset if either
+    // the density or view configuration changes (see implementation for more details).
+    private fun invalidatePointerInputModifiers() {
+        nodes.headToTail(type = SuspendPointerInput) {
+            it.resetBlock()
+        }
+    }
+
     /**
      * The measured width of this layout and all of its [modifier]s. Shortcut for `size.width`.
      */
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
index 04a697e..b451d40 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/NodeKind.kt
@@ -29,6 +29,7 @@
 import androidx.compose.ui.focus.FocusTargetModifierNode
 import androidx.compose.ui.input.key.KeyInputModifierNode
 import androidx.compose.ui.input.pointer.PointerInputModifier
+import androidx.compose.ui.input.pointer.SuspendPointerInputModifierNode
 import androidx.compose.ui.input.rotary.RotaryInputModifierNode
 import androidx.compose.ui.layout.IntermediateLayoutModifierNode
 import androidx.compose.ui.layout.LayoutModifier
@@ -39,6 +40,7 @@
 import androidx.compose.ui.modifier.ModifierLocalConsumer
 import androidx.compose.ui.modifier.ModifierLocalNode
 import androidx.compose.ui.modifier.ModifierLocalProvider
+import androidx.compose.ui.node.Nodes.SuspendPointerInput
 import androidx.compose.ui.semantics.SemanticsModifier
 
 @JvmInline
@@ -95,6 +97,8 @@
     @JvmStatic
     inline val CompositionLocalConsumer
         get() = NodeKind(0b1 shl 15)
+    @JvmStatic
+    inline val SuspendPointerInput get() = NodeKind(0b1 shl 16)
     // ...
 }
 
@@ -188,6 +192,10 @@
     if (node is CompositionLocalConsumerModifierNode) {
         mask = mask or Nodes.CompositionLocalConsumer
     }
+    if (node is SuspendPointerInputModifierNode) {
+        mask = mask or SuspendPointerInput
+    }
+
     return mask
 }
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
index deb97c5..c6a86b8 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/PointerInputModifierNode.kt
@@ -19,6 +19,7 @@
 import androidx.compose.ui.input.pointer.PointerEvent
 import androidx.compose.ui.input.pointer.PointerEventPass
 import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputFilter
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.unit.IntSize
 
@@ -28,7 +29,7 @@
  * [PointerInputModifierNode]s don't also react to them.
  *
  * This is the [androidx.compose.ui.Modifier.Node] equivalent of
- * [androidx.compose.ui.input.pointer.PointerInputModifier]
+ * [androidx.compose.ui.input.pointer.PointerInputFilter].
  *
  * @sample androidx.compose.ui.samples.PointerInputModifierNodeSample
  */
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
index 392192f..9f25017 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/SurfaceTest.kt
@@ -19,7 +19,6 @@
 import android.os.Build
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.awaitEachGesture
 import androidx.compose.foundation.interaction.FocusInteraction
 import androidx.compose.foundation.interaction.Interaction
 import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -44,8 +43,6 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.input.key.Key
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalFocusManager
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.Role
@@ -346,41 +343,6 @@
     }
 
     @Test
-    fun clickableSurface_allowsFinalPassChildren() {
-        val hitTested = mutableStateOf(false)
-
-        rule.setContent {
-            Box(Modifier.fillMaxSize()) {
-                Surface(
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .testTag("surface"),
-                    onClick = {}
-                ) {
-                    Box(
-                        Modifier
-                            .fillMaxSize()
-                            .testTag("pressable")
-                            .pointerInput(Unit) {
-                                awaitEachGesture {
-                                    hitTested.value = true
-                                    val event = awaitPointerEvent(PointerEventPass.Final)
-                                    Truth
-                                        .assertThat(event.changes[0].isConsumed)
-                                        .isFalse()
-                                }
-                            }
-                    )
-                }
-            }
-        }
-        rule.onNodeWithTag("surface").performSemanticsAction(SemanticsActions.RequestFocus)
-        rule.onNodeWithTag("pressable", true)
-            .performKeyInput { pressKey(Key.DirectionCenter) }
-        Truth.assertThat(hitTested.value).isTrue()
-    }
-
-    @Test
     fun clickableSurface_reactsToStateChange() {
         val interactionSource = MutableInteractionSource()
         var isPressed by mutableStateOf(false)
@@ -658,42 +620,6 @@
     }
 
     @Test
-    fun toggleableSurface_allowsFinalPassChildren() {
-        val hitTested = mutableStateOf(false)
-
-        rule.setContent {
-            Box(Modifier.fillMaxSize()) {
-                Surface(
-                    checked = false,
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .testTag("surface"),
-                    onCheckedChange = {}
-                ) {
-                    Box(
-                        Modifier
-                            .fillMaxSize()
-                            .testTag("pressable")
-                            .pointerInput(Unit) {
-                                awaitEachGesture {
-                                    hitTested.value = true
-                                    val event = awaitPointerEvent(PointerEventPass.Final)
-                                    Truth
-                                        .assertThat(event.changes[0].isConsumed)
-                                        .isFalse()
-                                }
-                            }
-                    )
-                }
-            }
-        }
-        rule.onNodeWithTag("surface").performSemanticsAction(SemanticsActions.RequestFocus)
-        rule.onNodeWithTag("pressable", true)
-            .performKeyInput { pressKey(Key.DirectionCenter) }
-        Truth.assertThat(hitTested.value).isTrue()
-    }
-
-    @Test
     fun toggleableSurface_reactsToStateChange() {
         val interactionSource = MutableInteractionSource()
         var isPressed by mutableStateOf(false)