Merge "Ink: Add Authoring module" into androidx-main
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 9264907..d6dd52b 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -208,6 +208,7 @@
     docs(project(":hilt:hilt-navigation-compose"))
     docs(project(":hilt:hilt-navigation-fragment"))
     docs(project(":hilt:hilt-work"))
+    kmpDocs(project(":ink:ink-authoring"))
     kmpDocs(project(":ink:ink-brush"))
     kmpDocs(project(":ink:ink-geometry"))
     kmpDocs(project(":ink:ink-nativeloader"))
diff --git a/ink/ink-authoring/api/current.txt b/ink/ink-authoring/api/current.txt
new file mode 100644
index 0000000..e6f50d0
--- /dev/null
+++ b/ink/ink-authoring/api/current.txt
@@ -0,0 +1 @@
+// Signature format: 4.0
diff --git a/ink/ink-authoring/api/res-current.txt b/ink/ink-authoring/api/res-current.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ink/ink-authoring/api/res-current.txt
diff --git a/ink/ink-authoring/api/restricted_current.txt b/ink/ink-authoring/api/restricted_current.txt
new file mode 100644
index 0000000..e6f50d0
--- /dev/null
+++ b/ink/ink-authoring/api/restricted_current.txt
@@ -0,0 +1 @@
+// Signature format: 4.0
diff --git a/ink/ink-authoring/build.gradle b/ink/ink-authoring/build.gradle
new file mode 100644
index 0000000..ddd631b
--- /dev/null
+++ b/ink/ink-authoring/build.gradle
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.build.LibraryType
+import androidx.build.PlatformIdentifier
+
+plugins {
+  id("AndroidXPlugin")
+  id("com.android.library")
+}
+
+androidXMultiplatform {
+  android()
+
+  defaultPlatform(PlatformIdentifier.ANDROID)
+
+  sourceSets {
+
+    androidMain {
+      dependencies {
+        implementation("androidx.collection:collection:1.4.3")
+        implementation("androidx.fragment:fragment-ktx:1.3.0")
+        implementation("androidx.test.espresso:espresso-idling-resource:3.5.0")
+        implementation(project(":core:core"))
+        implementation(project(":ink:ink-nativeloader"))
+        implementation(project(":ink:ink-geometry"))
+        implementation(project(":ink:ink-brush"))
+        implementation(project(":ink:ink-strokes"))
+        implementation(project(":ink:ink-rendering"))
+        implementation(project(":graphics:graphics-core"))
+      }
+    }
+
+    androidInstrumentedTest {
+      dependencies {
+        implementation(libs.testExtJunit)
+        implementation(libs.testRules)
+        implementation(libs.testRunner)
+        implementation(libs.espressoCore)
+        implementation(libs.junit)
+        implementation(libs.kotlinTest)
+        implementation(libs.mockitoCore4)
+        implementation(libs.mockitoKotlin4)
+        implementation(libs.dexmakerMockito)
+        implementation(libs.truth)
+        implementation(project(":test:screenshot:screenshot"))
+      }
+    }
+  }
+}
+
+android {
+  namespace = "androidx.ink.authoring"
+  compileSdk = 35
+  sourceSets.androidTest.assets.srcDirs +=
+      project.rootDir.absolutePath + "/../../golden/ink/ink-authoring"
+}
+
+androidx {
+    name = "Ink Authoring"
+    type = LibraryType.PUBLISHED_LIBRARY
+    inceptionYear = "2024"
+    description = "Author beautiful strokes"
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/AndroidManifest.xml b/ink/ink-authoring/src/androidInstrumentedTest/AndroidManifest.xml
new file mode 100644
index 0000000..5611078
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/AndroidManifest.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2024 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+  <application>
+    <activity
+        android:name="androidx.ink.authoring.InProgressStrokesViewTestActivity"
+        android:theme="@style/NoActionBar"
+        />
+    <activity
+        android:name="androidx.ink.authoring.internal.CanvasInProgressStrokesRenderHelperV33TestActivity"/>
+  </application>
+</manifest>
+
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTest.kt
new file mode 100644
index 0000000..7c13768
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTest.kt
@@ -0,0 +1,704 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Matrix
+import android.os.SystemClock
+import android.view.MotionEvent
+import androidx.annotation.ColorInt
+import androidx.ink.authoring.testing.InputStreamBuilder
+import androidx.ink.authoring.testing.MultiTouchInputBuilder
+import androidx.ink.brush.Brush
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+import androidx.ink.strokes.StrokeInput
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.test.screenshot.assertAgainstGolden
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Emulator-based test of [InProgressStrokesView]. */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class InProgressStrokesViewTest {
+
+    @get:Rule
+    val activityScenarioRule = ActivityScenarioRule(InProgressStrokesViewTestActivity::class.java)
+
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+
+    private val finishedStrokeCohorts = mutableListOf<Map<InProgressStrokeId, Stroke>>()
+    private val onStrokesFinishedListener =
+        object : InProgressStrokesFinishedListener {
+            override fun onStrokesFinished(strokes: Map<InProgressStrokeId, Stroke>) {
+                finishedStrokeCohorts.add(strokes)
+            }
+        }
+
+    @get:Rule val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_DIRECTORY)
+
+    /**
+     * Accumulates results for the entire test to minimize how often the tests need to be repeated
+     * when updates to the goldens are necessary.
+     */
+    private val screenshotFailureMessages = mutableListOf<String>()
+
+    @Before
+    fun setup() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.inProgressStrokesView.addFinishedStrokesListener(onStrokesFinishedListener)
+            activity.inProgressStrokesView.eagerInit()
+        }
+        yieldingSleep()
+    }
+
+    @Test
+    fun startStroke_showsStrokeWithNoCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25F, startY = 25F, endX = 105F, endY = 205F)
+        activityScenarioRule.scenario.onActivity { activity ->
+            @Suppress("UNUSED_VARIABLE")
+            val unused =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("start")
+        assertThat(finishedStrokeCohorts).isEmpty()
+    }
+
+    @Test
+    fun startAndAddToStroke_showsStrokeWithNoCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25F, startY = 25F, endX = 105F, endY = 205F)
+        activityScenarioRule.scenario.onActivity { activity ->
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+            activity.inProgressStrokesView.addToStroke(
+                stylusInputStream.getNextMoveEvent(),
+                pointerIndex = 0,
+                strokeId,
+                prediction = null,
+            )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("start_and_add")
+        assertThat(finishedStrokeCohorts).isEmpty()
+    }
+
+    @Test
+    fun startAndFinishStroke_showsStrokeAndSendsCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25F, startY = 25F, endX = 105F, endY = 205F)
+        activityScenarioRule.scenario.onActivity { activity ->
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+            activity.inProgressStrokesView.finishStroke(
+                stylusInputStream.getUpEvent(),
+                pointerIndex = 0,
+                strokeId,
+            )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("start_and_finish")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+        val stroke = finishedStrokeCohorts[0].values.iterator().next()
+
+        // Stroke units are set to pixels, so the stroke unit length should be 1/dpi inches, which
+        // is
+        // 2.54/dpi cm.
+        val metrics = InstrumentationRegistry.getInstrumentation().context.resources.displayMetrics
+        assertThat(stroke.inputs.getStrokeUnitLengthCm()).isWithin(1e-5f).of(2.54f / metrics.xdpi)
+    }
+
+    @Test
+    fun startAndFinishStroke_withNonIdentityTransforms() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25f, startY = 25f, endX = 105f, endY = 205f)
+        activityScenarioRule.scenario.onActivity { activity ->
+            val metrics = activity.resources.displayMetrics
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                    // MotionEvent space uses pixels, so this transform sets world units equal to
+                    // inches.
+                    motionEventToWorldTransform =
+                        Matrix().apply { setScale(1f / metrics.xdpi, 1f / metrics.ydpi) },
+                    // Set one stroke unit equal to half a world unit (i.e. half an inch).
+                    strokeToWorldTransform = Matrix().apply { setScale(0.5f, 0.5f) },
+                )
+            activity.inProgressStrokesView.finishStroke(
+                stylusInputStream.getUpEvent(),
+                pointerIndex = 0,
+                strokeId,
+            )
+        }
+
+        yieldingSleep()
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+        val stroke = finishedStrokeCohorts[0].values.iterator().next()
+
+        // With the transforms above, one stroke unit is 0.5 inches, which is 1.27 cm.
+        assertThat(stroke.inputs.getStrokeUnitLengthCm()).isWithin(1e-5f).of(1.27f)
+    }
+
+    @Test
+    fun startAndFinishStroke_withNonInvertibleTransforms() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25f, startY = 25f, endX = 105f, endY = 205f)
+        activityScenarioRule.scenario.onActivity { activity ->
+            assertThrows(IllegalArgumentException::class.java) {
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                    motionEventToWorldTransform = Matrix().apply { setScale(0f, 0f) },
+                )
+            }
+            assertThrows(IllegalArgumentException::class.java) {
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                    strokeToWorldTransform = Matrix().apply { setScale(0f, 0f) },
+                )
+            }
+        }
+    }
+
+    @Test
+    fun startAndCancelStroke_hidesStrokeWithNoCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(
+                startX = 25F,
+                startY = 25F,
+                endX = 105F,
+                endY = 205F,
+                endWithCancel = true,
+            )
+        lateinit var strokeId: InProgressStrokeId
+        activityScenarioRule.scenario.onActivity { activity ->
+            strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+        }
+        assertThatTakingScreenshotMatchesGolden("start_and_cancel_before_cancel")
+        assertThat(finishedStrokeCohorts).isEmpty()
+
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.inProgressStrokesView.cancelStroke(strokeId, stylusInputStream.getUpEvent())
+        }
+        assertThatTakingScreenshotMatchesGolden("start_and_cancel")
+        assertThat(finishedStrokeCohorts).isEmpty()
+    }
+
+    @Test
+    fun startAndAddToAndFinishStroke_showsStrokeAndSendsCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25F, startY = 25F, endX = 105F, endY = 205F)
+        activityScenarioRule.scenario.onActivity { activity ->
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+            activity.inProgressStrokesView.addToStroke(
+                stylusInputStream.getNextMoveEvent(),
+                pointerIndex = 0,
+                strokeId,
+                prediction = null,
+            )
+            activity.inProgressStrokesView.finishStroke(
+                stylusInputStream.getUpEvent(),
+                pointerIndex = 0,
+                strokeId,
+            )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("start_and_add_and_finish")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+    }
+
+    @Test
+    fun startAndAddToAndFinishStroke_showsStrokeAndSendsCallback_strokeInputApi() {
+        activityScenarioRule.scenario.onActivity { activity ->
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    StrokeInput.create(
+                        x = 25f,
+                        y = 25f,
+                        elapsedTimeMillis = 0,
+                        toolType = InputToolType.STYLUS,
+                    ),
+                    brush = basicBrush(TestColors.AVOCADO_GREEN),
+                )
+            activity.inProgressStrokesView.addToStroke(
+                MutableStrokeInputBatch().apply {
+                    addOrThrow(
+                        StrokeInput.create(
+                            x = 45f,
+                            y = 70f,
+                            elapsedTimeMillis = 5,
+                            toolType = InputToolType.STYLUS,
+                        )
+                    )
+                    addOrThrow(
+                        StrokeInput.create(
+                            x = 65f,
+                            y = 115f,
+                            elapsedTimeMillis = 10,
+                            toolType = InputToolType.STYLUS,
+                        )
+                    )
+                },
+                strokeId,
+            )
+            activity.inProgressStrokesView.finishStroke(
+                StrokeInput.create(
+                    x = 105f,
+                    y = 205f,
+                    elapsedTimeMillis = 20,
+                    toolType = InputToolType.STYLUS,
+                ),
+                strokeId,
+            )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("start_and_add_and_finish_stroke_input_api")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+    }
+
+    @Test
+    fun motionEventToViewAndStartAddFinishStroke_showsRepositionedStrokeAndSendsCallback() {
+        val stylusInputStream =
+            InputStreamBuilder.stylusLine(startX = 25F, startY = 25F, endX = 105F, endY = 205F)
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.inProgressStrokesView.motionEventToViewTransform =
+                Matrix().apply {
+                    postScale(1.5F, 1.5F)
+                    postRotate(/* degrees= */ 15F)
+                    postTranslate(100F, 200F)
+                }
+            val strokeId =
+                activity.inProgressStrokesView.startStroke(
+                    stylusInputStream.getDownEvent(),
+                    pointerIndex = 0,
+                    basicBrush(TestColors.AVOCADO_GREEN),
+                )
+            activity.inProgressStrokesView.addToStroke(
+                stylusInputStream.getNextMoveEvent(),
+                pointerIndex = 0,
+                strokeId,
+                prediction = null,
+            )
+            activity.inProgressStrokesView.finishStroke(
+                stylusInputStream.getUpEvent(),
+                pointerIndex = 0,
+                strokeId,
+            )
+        }
+
+        assertThatTakingScreenshotMatchesGolden("motion_event_to_view_transform")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+    }
+
+    @Test
+    fun twoSimultaneousStrokes_bothFinish_showsStrokesAndSendsCallbackAfterBothFinish() {
+        val inputStream =
+            MultiTouchInputBuilder.rotate90DegreesClockwise(centerX = 200F, centerY = 300F)
+        runMultiTouchGesture(inputStream)
+
+        assertThatTakingScreenshotMatchesGolden("two_simultaneous_both_finish")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(2)
+    }
+
+    @Test
+    fun twoSimultaneousStrokes_cancelThenFinish_showsFinishedStrokeAndSendsCallback() {
+        val inputStream =
+            MultiTouchInputBuilder.rotate90DegreesClockwise(centerX = 200F, centerY = 300F)
+        runMultiTouchGesture(inputStream, actionToCancel = MotionEvent.ACTION_POINTER_UP)
+
+        assertThatTakingScreenshotMatchesGolden("two_simultaneous_cancel_then_finish")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+    }
+
+    @Test
+    fun twoSimultaneousStrokes_finishThenCancel_showsFinishedStrokeAndSendsCallback() {
+        val inputStream =
+            MultiTouchInputBuilder.rotate90DegreesClockwise(centerX = 200F, centerY = 300F)
+        runMultiTouchGesture(inputStream, actionToCancel = MotionEvent.ACTION_UP)
+
+        assertThatTakingScreenshotMatchesGolden("two_simultaneous_finish_then_cancel")
+        assertThat(finishedStrokeCohorts).hasSize(1)
+        assertThat(finishedStrokeCohorts[0]).hasSize(1)
+    }
+
+    @Test
+    fun fiveSuccessiveStrokes_showsStrokesAndSendsFiveCallbacks() {
+        /**
+         * The key is chosen such that the resulting screenshot filenames are alphabetically in the
+         * same order in which they are produced, making it easier to follow the sequence of stroke
+         * events. To aid in that, choose [action] names that are in alphabetical order, e.g.
+         * prefixing their readable name with an action number.
+         *
+         * For successive strokes, each stroke has all the actions applied to it before moving onto
+         * the next stroke, so the stroke number comes before the action name in the file name.
+         */
+        fun screenshotKey(strokeCount: Int, action: String) =
+            "five_successive_stroke${strokeCount}_after_$action"
+
+        repeat(BRUSH_COLORS.size) { strokeIndex ->
+            val strokeCount = strokeIndex + 1
+            val stylusInputStream =
+                InputStreamBuilder.stylusLine(
+                    startX = 15F * strokeCount,
+                    startY = 45F * strokeCount,
+                    endX = 400F - 10F * strokeCount,
+                    endY = 600F - 35F * strokeCount,
+                )
+            lateinit var strokeId: InProgressStrokeId
+            activityScenarioRule.scenario.onActivity { activity ->
+                strokeId =
+                    activity.inProgressStrokesView.startStroke(
+                        stylusInputStream.getDownEvent(),
+                        pointerIndex = 0,
+                        basicBrush(BRUSH_COLORS[strokeIndex]),
+                    )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step1start"))
+            assertThat(finishedStrokeCohorts).hasSize(strokeIndex)
+
+            activityScenarioRule.scenario.onActivity { activity ->
+                activity.inProgressStrokesView.addToStroke(
+                    stylusInputStream.getNextMoveEvent(),
+                    pointerIndex = 0,
+                    strokeId,
+                    prediction = null,
+                )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step2add"))
+            assertThat(finishedStrokeCohorts).hasSize(strokeIndex)
+
+            activityScenarioRule.scenario.onActivity { activity ->
+                activity.inProgressStrokesView.finishStroke(
+                    stylusInputStream.getUpEvent(),
+                    pointerIndex = 0,
+                    strokeId,
+                )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step3finish"))
+            assertThat(finishedStrokeCohorts).hasSize(strokeCount)
+            assertThat(finishedStrokeCohorts[strokeIndex]).hasSize(1)
+        }
+    }
+
+    @Test
+    fun fiveSimultaneousStokes_showsStrokesAndSendsOneCallback() {
+        /**
+         * The key is chosen such that the resulting screenshot filenames are alphabetically in the
+         * same order in which they are produced, making it easier to follow the sequence of stroke
+         * events. To aid in that, choose [action] names that are in alphabetical order, e.g.
+         * prefixing their readable name with an action number.
+         *
+         * For simultaneous strokes, each action is applied to all the strokes before moving onto
+         * the next action, so the action name comes before the stroke number in the file name.
+         */
+        fun screenshotKey(strokeCount: Int, action: String) =
+            "five_simultaneous_after_${action}_stroke$strokeCount"
+
+        val stylusInputStreams =
+            BRUSH_COLORS.indices.map { strokeIndex ->
+                val strokeCount = strokeIndex + 1
+                InputStreamBuilder.stylusLine(
+                    startX = 15F * strokeCount,
+                    startY = 45F * strokeCount,
+                    endX = 400F - 10F * strokeCount,
+                    endY = 600F - 35F * strokeCount,
+                )
+            }
+        val strokeIds = Array<InProgressStrokeId?>(stylusInputStreams.size) { null }
+        for (strokeIndex in strokeIds.indices) {
+            val strokeCount = strokeIndex + 1
+            activityScenarioRule.scenario.onActivity { activity ->
+                strokeIds[strokeIndex] =
+                    activity.inProgressStrokesView.startStroke(
+                        stylusInputStreams[strokeIndex].getDownEvent(),
+                        pointerIndex = 0,
+                        basicBrush(BRUSH_COLORS[strokeIndex]),
+                    )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step1start"))
+            assertThat(finishedStrokeCohorts).isEmpty()
+        }
+
+        for (strokeIndex in strokeIds.indices) {
+            val strokeCount = strokeIndex + 1
+            activityScenarioRule.scenario.onActivity { activity ->
+                activity.inProgressStrokesView.addToStroke(
+                    stylusInputStreams[strokeIndex].getNextMoveEvent(),
+                    pointerIndex = 0,
+                    checkNotNull(strokeIds[strokeIndex]),
+                    prediction = null,
+                )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step2add"))
+            assertThat(finishedStrokeCohorts).isEmpty()
+        }
+
+        for (strokeIndex in strokeIds.indices) {
+            val strokeCount = strokeIndex + 1
+            activityScenarioRule.scenario.onActivity { activity ->
+                activity.inProgressStrokesView.finishStroke(
+                    stylusInputStreams[strokeIndex].getUpEvent(),
+                    pointerIndex = 0,
+                    checkNotNull(strokeIds[strokeIndex]),
+                )
+            }
+            assertThatTakingScreenshotMatchesGolden(screenshotKey(strokeCount, "step3finish"))
+            if (strokeCount == strokeIds.size) {
+                assertThat(finishedStrokeCohorts).hasSize(1)
+                assertThat(finishedStrokeCohorts[0]).hasSize(strokeIds.size)
+            } else {
+                assertThat(finishedStrokeCohorts).isEmpty()
+            }
+        }
+    }
+
+    @Test
+    fun removeFinishedStrokes_showsNoMoreStrokes() {
+        val strokeIds = mutableSetOf<InProgressStrokeId>()
+        activityScenarioRule.scenario.onActivity { activity ->
+            repeat(BRUSH_COLORS.size) { strokeIndex ->
+                val strokeCount = strokeIndex + 1
+                val stylusInputStream =
+                    InputStreamBuilder.stylusLine(
+                        startX = 15F * strokeCount,
+                        startY = 45F * strokeCount,
+                        endX = 400F - 10F * strokeCount,
+                        endY = 600F - 35F * strokeCount,
+                    )
+                val strokeId =
+                    activity.inProgressStrokesView.startStroke(
+                        stylusInputStream.getDownEvent(),
+                        pointerIndex = 0,
+                        basicBrush(BRUSH_COLORS[strokeIndex]),
+                    )
+                strokeIds.add(strokeId)
+                activity.inProgressStrokesView.addToStroke(
+                    stylusInputStream.getNextMoveEvent(),
+                    pointerIndex = 0,
+                    strokeId,
+                    prediction = null,
+                )
+                activity.inProgressStrokesView.finishStroke(
+                    stylusInputStream.getUpEvent(),
+                    pointerIndex = 0,
+                    strokeId,
+                )
+            }
+        }
+
+        assertThatTakingScreenshotMatchesGolden("remove_finished_before_remove")
+        // Don't care how they were grouped together for the callback, just that they all arrived.
+        assertThat(finishedStrokeCohorts.sumOf(Map<InProgressStrokeId, Stroke>::size)).isEqualTo(5)
+        val finishedStrokeIds = mutableSetOf<InProgressStrokeId>()
+        for (cohort in finishedStrokeCohorts) {
+            finishedStrokeIds.addAll(cohort.keys)
+        }
+        assertThat(finishedStrokeIds).containsExactlyElementsIn(strokeIds)
+
+        assertThat(strokeIds).hasSize(5)
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.inProgressStrokesView.removeFinishedStrokes(strokeIds)
+        }
+        assertThatTakingScreenshotMatchesGolden("remove_finished")
+    }
+
+    private fun runMultiTouchGesture(
+        inputStream: MultiTouchInputBuilder,
+        actionToCancel: Int? = null,
+    ) {
+        activityScenarioRule.scenario.onActivity { activity ->
+            val pointerIdToStrokeId = mutableMapOf<Int, InProgressStrokeId>()
+            inputStream.runGestureWith { event ->
+                when (event.actionMasked) {
+                    MotionEvent.ACTION_DOWN,
+                    MotionEvent.ACTION_POINTER_DOWN -> {
+                        val pointerIndex = event.actionIndex
+                        val pointerId = event.getPointerId(pointerIndex)
+                        pointerIdToStrokeId[pointerId] =
+                            activity.inProgressStrokesView.startStroke(
+                                event,
+                                pointerIndex,
+                                basicBrush(color = BRUSH_COLORS[pointerIdToStrokeId.size]),
+                            )
+                    }
+                    MotionEvent.ACTION_MOVE -> {
+                        for (pointerIndex in 0 until event.pointerCount) {
+                            val pointerId = event.getPointerId(pointerIndex)
+                            val strokeId = checkNotNull(pointerIdToStrokeId[pointerId])
+                            activity.inProgressStrokesView.addToStroke(
+                                event,
+                                pointerIndex,
+                                strokeId,
+                                prediction = null,
+                            )
+                        }
+                    }
+                    MotionEvent.ACTION_POINTER_UP,
+                    MotionEvent.ACTION_UP -> {
+                        val pointerIndex = event.actionIndex
+                        val pointerId = event.getPointerId(pointerIndex)
+                        val strokeId = checkNotNull(pointerIdToStrokeId[pointerId])
+                        if (event.actionMasked == actionToCancel) {
+                            activity.inProgressStrokesView.cancelStroke(strokeId, event)
+                        } else {
+                            activity.inProgressStrokesView.finishStroke(
+                                event,
+                                pointerIndex,
+                                strokeId
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Waits for actions to complete, both on the render thread and the UI thread, for a specified
+     * period of time. The default time is 1 second.
+     */
+    private fun yieldingSleep(timeMs: Long = 1000) {
+        activityScenarioRule.scenario.onActivity { activity ->
+            // Ensures that everything in the action queue before this point has been processed.
+            activity.inProgressStrokesView.sync(timeMs, TimeUnit.MILLISECONDS)
+        }
+        repeat((timeMs / SLEEP_INTERVAL_MS).toInt()) {
+            onIdle()
+            SystemClock.sleep(SLEEP_INTERVAL_MS)
+        }
+    }
+
+    /**
+     * Take screenshots of the entire device rather than just a View in order to include all layers
+     * being composed on screen. This will include the front buffer layer.
+     * [InProgressStrokesViewTestActivity] is set up to exclude parts of the screen that are
+     * irrelevant and may just cause flakes, such as the status bar and toolbar.
+     */
+    private fun assertThatTakingScreenshotMatchesGolden(key: String) {
+        // Save just one failure message despite multiple attempts to improve the signal-to-noise
+        // ratio.
+        var lastFailureMessage: String? = null
+        for (attempt in 0 until SCREENSHOT_RETRY_COUNT) {
+            val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
+            if (bitmap != null) {
+                lastFailureMessage = compareAgainstGolden(bitmap, key) ?: return
+            }
+            yieldingSleep(500L * (1 shl attempt))
+        }
+        // Don't fail right away, but accumulate results for the entire test.
+        screenshotFailureMessages.add(checkNotNull(lastFailureMessage))
+    }
+
+    /**
+     * Returns `null` if [bitmap] matches the golden image for [key], or a non-null error message if
+     * they do not match.
+     */
+    private fun compareAgainstGolden(bitmap: Bitmap, key: String): String? {
+        // The only function available is an assertion, so wrap the thrown exception and treat it as
+        // a single failure in a sequence of retries. Will be rethrown at the end of the test if
+        // appropriate (see `cleanup`).
+        try {
+            bitmap.assertAgainstGolden(screenshotRule, "${this::class.simpleName}_$key")
+            return null
+        } catch (e: AssertionError) {
+            return e.message ?: "Image comparison failure"
+        }
+    }
+
+    @After
+    fun cleanup() {
+        if (screenshotFailureMessages.isNotEmpty()) {
+            throw AssertionError(
+                "At least one screenshot did not match goldens:\n$screenshotFailureMessages"
+            )
+        }
+    }
+
+    private fun basicBrush(@ColorInt color: Int) =
+        Brush.createWithColorIntArgb(
+            family = StockBrushes.markerLatest,
+            colorIntArgb = color,
+            size = 25F,
+            epsilon = 0.1F,
+        )
+
+    private companion object {
+        const val SLEEP_INTERVAL_MS = 100L
+        const val SCREENSHOT_RETRY_COUNT = 4
+
+        val BRUSH_COLORS =
+            listOf(
+                TestColors.AVOCADO_GREEN,
+                TestColors.HOT_PINK,
+                TestColors.COBALT_BLUE,
+                TestColors.ORANGE,
+                TestColors.DEEP_PURPLE,
+            )
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTestActivity.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTestActivity.kt
new file mode 100644
index 0000000..cf68f1f
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/InProgressStrokesViewTestActivity.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import android.app.Activity
+import android.os.Bundle
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+/** An [Activity] to support [InProgressStrokesViewTest] by rendering a simple stroke. */
+class InProgressStrokesViewTestActivity : Activity() {
+
+    lateinit var inProgressStrokesView: InProgressStrokesView
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        inProgressStrokesView = InProgressStrokesView(this)
+
+        @Suppress("DEPRECATION")
+        inProgressStrokesView.useNewTPlusRenderHelper = true
+
+        setContentView(inProgressStrokesView)
+
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        WindowInsetsControllerCompat(window, inProgressStrokesView).let { controller ->
+            controller.hide(WindowInsetsCompat.Type.systemBars())
+            controller.systemBarsBehavior =
+                WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestColors.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestColors.kt
new file mode 100644
index 0000000..34fa7ca
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestColors.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import androidx.annotation.ColorInt
+
+/**
+ * [ColorInt] constants for use in tests.
+ *
+ * Channels are in ARGB order, per the definition of [ColorInt]. Use the helper functions defined
+ * below to convert to other channel orders.
+ *
+ * These colors have different values for all RGB channels and at least one channel with a value
+ * strictly between 0.0 (0x00) and 1.0 (0xff). These properties help check for channel order
+ * scrambling (for example, incorrect mixing of RGB and BGR formats) and gamma correction errors.
+ */
+object TestColors {
+    /**
+     * Near-white color for backgrounds and elements without textures. For textured elements that
+     * need a 100% white base color, use [WHITE_FOR_TEXTURE].
+     */
+    @ColorInt const val WHITE = 0xfff5f8ff.toInt()
+    // Gray and black are not pure desaturated tones, because we need different values in the
+    // different channels.
+    @ColorInt const val LIGHT_GRAY = 0xffbaccc0.toInt()
+    @ColorInt const val DARK_GRAY = 0xff4d4239.toInt()
+    @ColorInt const val BLACK = 0xff290e1c.toInt()
+    @ColorInt const val RED = 0xfff7251e.toInt()
+    @ColorInt const val ORANGE = 0xffff6e40.toInt()
+    @ColorInt const val LIGHT_ORANGE = 0xffffccbc.toInt()
+    @ColorInt const val YELLOW = 0xfff7f12d.toInt()
+    @ColorInt const val AVOCADO_GREEN = 0xff558b2f.toInt()
+    @ColorInt const val GREEN = 0xff00c853.toInt()
+    @ColorInt const val CYAN = 0xff2be3f0.toInt()
+    @ColorInt const val LIGHT_BLUE = 0xff4fb5e8.toInt()
+    @ColorInt const val BLUE = 0xff304ffe.toInt()
+    @ColorInt const val COBALT_BLUE = 0xff01579b.toInt()
+    @ColorInt const val DEEP_PURPLE = 0xff8e24aa.toInt()
+    @ColorInt const val MAGENTA = 0xffed26e0.toInt()
+    @ColorInt const val HOT_PINK = 0xffff4081.toInt()
+
+    /** White base color for elements that have a texture applied. */
+    @ColorInt const val WHITE_FOR_TEXTURE = 0xffffffff.toInt()
+
+    @ColorInt const val TRANSLUCENT_ORANGE = 0x80ffbf00.toInt()
+
+    @JvmStatic
+    fun colorIntToRgba(@ColorInt argb: Int): Int = (argb shl 8) or ((argb shr 24) and 0xff)
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestConstants.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestConstants.kt
new file mode 100644
index 0000000..f82fac1
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/TestConstants.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+internal const val SCREENSHOT_GOLDEN_DIRECTORY = "ink/ink-authoring"
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUpTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUpTest.kt
new file mode 100644
index 0000000..a06e702
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUpTest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+@SmallTest
+class AtMostOnceAfterSetUpTest {
+
+    @Test
+    fun setUpOnce_callbackExecutesOnce() {
+        var callbackCounter = 0
+        val callback: () -> Unit = { callbackCounter++ }
+        val callbackAtMostOnce = AtMostOnceAfterSetUp(callback)
+
+        val runnable = callbackAtMostOnce.setUp()
+        assertThat(callbackCounter).isEqualTo(0)
+        runnable.run()
+        assertThat(callbackCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setUpMultipleTimes_callbackExecutesOnce() {
+        var callbackCounter = 0
+        val callback: () -> Unit = { callbackCounter++ }
+        val callbackAtMostOnce = AtMostOnceAfterSetUp(callback)
+
+        val runnables =
+            listOf(
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp(),
+            )
+        assertThat(callbackCounter).isEqualTo(0)
+        runnables.forEach(Runnable::run)
+        assertThat(callbackCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun setUpAgainAfterRun_callbackExecutesAgain() {
+        var callbackCounter = 0
+        val callback: () -> Unit = { callbackCounter++ }
+        val callbackAtMostOnce = AtMostOnceAfterSetUp(callback)
+
+        val runnables =
+            listOf(
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp(),
+                callbackAtMostOnce.setUp()
+            )
+        assertThat(callbackCounter).isEqualTo(0)
+        runnables.forEach(Runnable::run)
+        assertThat(callbackCounter).isEqualTo(1)
+
+        val runnables2 = listOf(callbackAtMostOnce.setUp(), callbackAtMostOnce.setUp())
+        assertThat(callbackCounter).isEqualTo(1)
+        runnables2.forEach(Runnable::run)
+        assertThat(callbackCounter).isEqualTo(2)
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33Test.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33Test.kt
new file mode 100644
index 0000000..f96fa79
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33Test.kt
@@ -0,0 +1,366 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import android.os.Build
+import android.os.SystemClock
+import android.view.SurfaceView
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.internal.CanvasInProgressStrokesRenderHelperV33.Bounds
+import androidx.ink.brush.Brush
+import androidx.ink.brush.StockBrushes
+import androidx.ink.geometry.MutableBox
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+/**
+ * Logic test of [CanvasInProgressStrokesRenderHelperV33]. Runs on an emulator to avoid issues with
+ * APIs like SurfaceControl and SurfaceControl.Transaction, which don't have Robolectric shadows or
+ * other fakes. The interactions with those objects can't really be verified, so this test will
+ * focus on its public API rather than system side effects - namely, calling functions and verifying
+ * that the appropriate callbacks are executed. Although this is an emulator test, it does not do
+ * any screenshot comparison. That is the role of [InProgressStrokesViewTest], which is a bit higher
+ * level but covers the functionality of [CanvasInProgressStrokesRenderHelperV33] in a different way
+ * than this test.
+ */
+@OptIn(ExperimentalLatencyDataApi::class)
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@SdkSuppress(
+    minSdkVersion = Build.VERSION_CODES.TIRAMISU,
+    maxSdkVersion = Build.VERSION_CODES.TIRAMISU,
+)
+class CanvasInProgressStrokesRenderHelperV33Test {
+
+    @get:Rule
+    val activityScenarioRule =
+        ActivityScenarioRule(CanvasInProgressStrokesRenderHelperV33TestActivity::class.java)
+
+    private val renderer = mock<CanvasStrokeRenderer> {}
+    private val callback = mock<InProgressStrokesRenderHelper.Callback> {}
+
+    @Test
+    fun init_shouldAddSurfaceViewAndRunUiThreadTasks() {
+        withActivity { activity ->
+            assertThat(activity.mainView.childCount).isEqualTo(1)
+            assertThat(activity.mainView.getChildAt(0)).isInstanceOf(SurfaceView::class.java)
+        }
+        var ranAnyOnUiThread = false
+        for (i in 0 until 3) {
+            onIdle()
+            withActivity { activity ->
+                if (activity.fakeThreads.runUiThreadToIdle()) {
+                    ranAnyOnUiThread = true
+                }
+            }
+            SystemClock.sleep(1000)
+        }
+        assertThat(ranAnyOnUiThread).isTrue()
+    }
+
+    @Test
+    fun requestDraw_whenNotInitialized_schedulesTaskOnRenderThread() {
+        withActivity { activity ->
+            activity.renderHelper.requestDraw()
+            assertThat(activity.fakeThreads.runRenderThreadToIdle()).isTrue()
+        }
+    }
+
+    @Test
+    fun requestDraw_runsCallbackOnDrawAndOnDrawComplete() {
+        var ranAnyOnUiThread = false
+        for (i in 0 until 3) {
+            onIdle()
+            withActivity { activity ->
+                if (activity.fakeThreads.runUiThreadToIdle()) {
+                    ranAnyOnUiThread = true
+                }
+            }
+            SystemClock.sleep(1000)
+        }
+        // Make sure it fully initialized.
+        assertThat(ranAnyOnUiThread).isTrue()
+
+        withActivity { activity ->
+            activity.renderHelper.requestDraw()
+
+            assertThat(activity.fakeThreads.runRenderThreadToIdle()).isTrue()
+            verify(callback).onDraw()
+            verify(callback).onDrawComplete()
+        }
+    }
+
+    @Test
+    fun requestDraw_whenCalledAgainBeforeDrawFinished_nextDrawIsQueuedAndBothHandOffLatencyData() {
+        var ranAnyOnUiThread = false
+        for (i in 0 until 3) {
+            onIdle()
+            withActivity { activity ->
+                if (activity.fakeThreads.runUiThreadToIdle()) {
+                    ranAnyOnUiThread = true
+                }
+            }
+            SystemClock.sleep(1000)
+        }
+        // Make sure it fully initialized.
+        assertThat(ranAnyOnUiThread).isTrue()
+
+        withActivity { activity ->
+            // Two draw requests, with a render thread executions for each of their top level render
+            // thread scheduled tasks.
+            activity.renderHelper.requestDraw()
+            activity.renderHelper.requestDraw()
+            assertThat(activity.fakeThreads.runRenderThreadOnce()).isTrue()
+            assertThat(activity.fakeThreads.runRenderThreadOnce()).isTrue()
+
+            // onDraw and onDrawComplete executed just for the first draw request.
+            verify(callback, times(1)).onDraw()
+            verify(callback, times(1)).onDrawComplete()
+            verify(callback, never()).setCustomLatencyDataField(any())
+            verify(callback, never()).handOffAllLatencyData()
+        }
+
+        // The draw request may be async outside of our code's control, so wait for it to finish,
+        // and
+        // run any render thread tasks that it enqueues.
+        var ranAnyOnRenderThread = false
+        for (i in 0 until 3) {
+            onIdle()
+            withActivity { activity ->
+                if (activity.fakeThreads.runRenderThreadToIdle()) {
+                    ranAnyOnRenderThread = true
+                }
+            }
+            SystemClock.sleep(1000)
+        }
+        assertThat(ranAnyOnRenderThread).isTrue()
+
+        // Now the second draw was able to execute onDraw and onDrawComplete.
+        verify(callback, times(2)).onDraw()
+        verify(callback, times(2)).onDrawComplete()
+        verify(callback, times(2)).setCustomLatencyDataField(any())
+        verify(callback, times(2)).handOffAllLatencyData()
+    }
+
+    @Test
+    fun drawInModifiedRegion_callsRenderer() {
+        var ranAnyOnUiThread = false
+        for (i in 0 until 3) {
+            onIdle()
+            withActivity { activity ->
+                if (activity.fakeThreads.runUiThreadToIdle()) {
+                    ranAnyOnUiThread = true
+                }
+            }
+            SystemClock.sleep(1000)
+        }
+        // Make sure it fully initialized.
+        assertThat(ranAnyOnUiThread).isTrue()
+
+        withActivity { activity ->
+            whenever(callback.onDraw()).then {
+                activity.renderHelper.prepareToDrawInModifiedRegion(MutableBox())
+                activity.renderHelper.drawInModifiedRegion(InProgressStroke(), Matrix())
+
+                activity.renderHelper.afterDrawInModifiedRegion()
+            }
+
+            activity.renderHelper.requestDraw()
+            assertThat(activity.fakeThreads.runRenderThreadOnce()).isTrue()
+
+            // onDraw and onDrawComplete executed just for the first draw request.
+            verify(callback, times(1)).onDraw()
+            // The [InProgressStroke] above is expected to be drawn by the [renderer], and the
+            // legacy
+            // [LegacyStrokeBuilder] is expected to be drawn by the [legacyRenderer].
+            verify(renderer, times(1)).draw(any(), any<InProgressStroke>(), any<Matrix>())
+
+            verify(callback, times(1)).onDrawComplete()
+        }
+    }
+
+    @Test
+    fun requestStrokeCohortHandoffToHwui_shouldExecuteCallbackHandoffAndPauseHandoffs() {
+        run {
+            var ranAnyOnUiThread = false
+            for (i in 0 until 3) {
+                onIdle()
+                withActivity { activity ->
+                    if (activity.fakeThreads.runUiThreadToIdle()) {
+                        ranAnyOnUiThread = true
+                    }
+                }
+                SystemClock.sleep(1000)
+            }
+            // Make sure it fully initialized.
+            assertThat(ranAnyOnUiThread).isTrue()
+        }
+
+        withActivity { activity ->
+            val brush = Brush(family = StockBrushes.markerLatest, size = 10f, epsilon = 0.1f)
+            val stroke = Stroke(brush, ImmutableStrokeInputBatch.EMPTY)
+            val handingOff = mapOf(InProgressStrokeId() to FinishedStroke(stroke, Matrix()))
+            activity.renderHelper.requestStrokeCohortHandoffToHwui(handingOff)
+            verify(callback).setPauseStrokeCohortHandoffs(true)
+            verify(callback).onStrokeCohortHandoffToHwui(handingOff)
+            verify(callback).onStrokeCohortHandoffToHwuiComplete()
+
+            activity.fakeThreads.runOnRenderThread { activity.renderHelper.clear() }
+            assertThat(activity.fakeThreads.uiThreadDelayedTaskCount()).isEqualTo(1)
+            assertThat(activity.fakeThreads.uiThreadReadyTaskCount()).isEqualTo(0)
+
+            activity.fakeThreads.clock.currentTimeMillis += 1000
+            assertThat(activity.fakeThreads.uiThreadDelayedTaskCount()).isEqualTo(0)
+            assertThat(activity.fakeThreads.uiThreadReadyTaskCount()).isEqualTo(1)
+            assertThat(activity.fakeThreads.runUiThreadToIdle()).isTrue()
+        }
+
+        // The draw request may be async outside of our code's control, so wait for it to finish,
+        // and
+        // run any UI thread tasks that it enqueues.
+        run {
+            for (i in 0 until 3) {
+                onIdle()
+                withActivity { activity -> activity.fakeThreads.runUiThreadToIdle() }
+                SystemClock.sleep(250)
+            }
+        }
+
+        verify(callback).setPauseStrokeCohortHandoffs(false)
+    }
+
+    @Test
+    fun onViewDetachedFromWindow_shouldRemoveSurfaceView() {
+        withActivity { activity ->
+            activity.rootView.removeView(activity.mainView)
+            assertThat(activity.mainView.childCount).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun boundsInit_handlesAllRotationTransformHints() {
+        val mainViewWidth = 111
+        val mainViewHeight = 444
+
+        with(
+            Bounds(
+                mainViewWidth = mainViewWidth,
+                mainViewHeight = mainViewHeight,
+                mainViewTransformHint = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
+            )
+        ) {
+            assertThat(bufferWidth).isEqualTo(mainViewWidth)
+            assertThat(bufferHeight).isEqualTo(mainViewHeight)
+            assertThat(bufferTransform).isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY)
+            assertThat(bufferTransformInverse)
+                .isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY)
+        }
+
+        with(
+            Bounds(
+                mainViewWidth = mainViewWidth,
+                mainViewHeight = mainViewHeight,
+                mainViewTransformHint = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180,
+            )
+        ) {
+            assertThat(bufferWidth).isEqualTo(mainViewWidth)
+            assertThat(bufferHeight).isEqualTo(mainViewHeight)
+            assertThat(bufferTransform).isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180)
+            assertThat(bufferTransformInverse)
+                .isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180)
+        }
+
+        with(
+            Bounds(
+                mainViewWidth = mainViewWidth,
+                mainViewHeight = mainViewHeight,
+                mainViewTransformHint = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90,
+            )
+        ) {
+            assertThat(bufferWidth).isEqualTo(mainViewHeight)
+            assertThat(bufferHeight).isEqualTo(mainViewWidth)
+            assertThat(bufferTransform).isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90)
+            assertThat(bufferTransformInverse)
+                .isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270)
+        }
+
+        with(
+            Bounds(
+                mainViewWidth = mainViewWidth,
+                mainViewHeight = mainViewHeight,
+                mainViewTransformHint = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270,
+            )
+        ) {
+            assertThat(bufferWidth).isEqualTo(mainViewHeight)
+            assertThat(bufferHeight).isEqualTo(mainViewWidth)
+            assertThat(bufferTransform).isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270)
+            assertThat(bufferTransformInverse)
+                .isEqualTo(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90)
+        }
+
+        with(
+            Bounds(
+                mainViewWidth = mainViewWidth,
+                mainViewHeight = mainViewHeight,
+                // Unsupported value
+                mainViewTransformHint = SurfaceControlCompat.BUFFER_TRANSFORM_MIRROR_HORIZONTAL,
+            )
+        ) {
+            assertThat(bufferWidth).isEqualTo(mainViewWidth)
+            assertThat(bufferHeight).isEqualTo(mainViewHeight)
+            assertThat(bufferTransform).isNull()
+            assertThat(bufferTransformInverse).isNull()
+        }
+    }
+
+    private fun withActivity(block: (CanvasInProgressStrokesRenderHelperV33TestActivity) -> Unit) {
+        activityScenarioRule.scenario.onActivity { activity ->
+            activity.renderer = renderer
+            activity.callback = callback
+            activity.fakeThreads.runOnUiThread {
+                // Code run within onActivity can be considered to be on the UI thread for
+                // assertions. There
+                // is no equivalent of this for the render thread, since the render thread is only
+                // accessed
+                // by scheduling tasks on the render thread executors, while the UI thread is used
+                // by all
+                // many standard system callbacks.
+                block(activity)
+            }
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33TestActivity.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33TestActivity.kt
new file mode 100644
index 0000000..98014f4
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33TestActivity.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.os.Build
+import android.os.Bundle
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.RequiresApi
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.geometry.AffineTransform
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.Stroke
+import java.util.concurrent.TimeUnit
+
+/** An [Activity] to support [CanvasInProgressStrokesRenderHelperV33]. */
+@OptIn(ExperimentalLatencyDataApi::class)
+@SuppressLint("UseSdkSuppress") // SdkSuppress is on the test class.
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+class CanvasInProgressStrokesRenderHelperV33TestActivity : Activity() {
+
+    lateinit var rootView: ViewGroup
+    lateinit var mainView: ViewGroup
+    internal lateinit var renderHelper: CanvasInProgressStrokesRenderHelperV33
+
+    internal var fakeThreads = FakeThreads()
+
+    internal var callback: InProgressStrokesRenderHelper.Callback? = null
+    var renderer: CanvasStrokeRenderer? = null
+
+    private val delegatingCallback =
+        object : InProgressStrokesRenderHelper.Callback {
+            override fun onDraw() {
+                callback?.onDraw()
+            }
+
+            override fun onDrawComplete() {
+                callback?.onDrawComplete()
+            }
+
+            override fun reportEstimatedPixelPresentationTime(timeNanos: Long) {
+                callback?.reportEstimatedPixelPresentationTime(timeNanos)
+            }
+
+            override fun setCustomLatencyDataField(setter: (LatencyData, Long) -> Unit) {
+                callback?.setCustomLatencyDataField(setter)
+            }
+
+            override fun handOffAllLatencyData() {
+                callback?.handOffAllLatencyData()
+            }
+
+            override fun setPauseStrokeCohortHandoffs(paused: Boolean) {
+                callback?.setPauseStrokeCohortHandoffs(paused)
+            }
+
+            override fun onStrokeCohortHandoffToHwui(
+                strokeCohort: Map<InProgressStrokeId, FinishedStroke>
+            ) {
+                callback?.onStrokeCohortHandoffToHwui(strokeCohort)
+            }
+
+            override fun onStrokeCohortHandoffToHwuiComplete() {
+                callback?.onStrokeCohortHandoffToHwuiComplete()
+            }
+        }
+
+    private val delegatingRenderer =
+        object : CanvasStrokeRenderer {
+            override fun draw(
+                canvas: Canvas,
+                stroke: Stroke,
+                strokeToCanvasTransform: AffineTransform
+            ) {
+                renderer?.draw(canvas, stroke, strokeToCanvasTransform)
+            }
+
+            override fun draw(canvas: Canvas, stroke: Stroke, strokeToCanvasTransform: Matrix) {
+                renderer?.draw(canvas, stroke, strokeToCanvasTransform)
+            }
+
+            override fun draw(
+                canvas: Canvas,
+                inProgressStroke: InProgressStroke,
+                strokeToCanvasTransform: AffineTransform,
+            ) {
+                renderer?.draw(canvas, inProgressStroke, strokeToCanvasTransform)
+            }
+
+            override fun draw(
+                canvas: Canvas,
+                inProgressStroke: InProgressStroke,
+                strokeToCanvasTransform: Matrix,
+            ) {
+                renderer?.draw(canvas, inProgressStroke, strokeToCanvasTransform)
+            }
+        }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        rootView = FrameLayout(this)
+        mainView = FrameLayout(this)
+        rootView.addView(mainView)
+        setContentView(rootView)
+
+        renderHelper =
+            CanvasInProgressStrokesRenderHelperV33(
+                mainView,
+                delegatingCallback,
+                delegatingRenderer,
+                fakeThreads.uiThreadExecutors,
+                fakeThreads.renderThreadExecutors,
+            )
+    }
+
+    internal class FakeThreads {
+        enum class ThreadId {
+            TEST,
+            UI,
+            RENDER,
+        }
+
+        private var currentThreadId = ThreadId.TEST
+
+        val clock =
+            FakeClock(1000) { newTimeMillis ->
+                _uiThreadExecutors.onNewTime(newTimeMillis)
+                _renderThreadExecutors.onNewTime(newTimeMillis)
+            }
+
+        private val _uiThreadExecutors = FakeScheduledExecutor(ThreadId.UI)
+        val uiThreadExecutors: CanvasInProgressStrokesRenderHelperV33.ScheduledExecutor =
+            _uiThreadExecutors
+
+        private val _renderThreadExecutors = FakeScheduledExecutor(ThreadId.RENDER)
+        val renderThreadExecutors: CanvasInProgressStrokesRenderHelperV33.ScheduledExecutor =
+            _renderThreadExecutors
+
+        fun uiThreadReadyTaskCount() = _uiThreadExecutors.tasks.size
+
+        fun renderThreadReadyTaskCount() = _renderThreadExecutors.tasks.size
+
+        fun uiThreadDelayedTaskCount() = _uiThreadExecutors.delayedTasks.size
+
+        fun renderThreadDelayedTaskCount() = _renderThreadExecutors.delayedTasks.size
+
+        fun runUiThreadOnce() = runFakeThreadOnce(_uiThreadExecutors)
+
+        fun runRenderThreadOnce() = runFakeThreadOnce(_renderThreadExecutors)
+
+        fun runUiThreadToIdle() = runFakeThreadToIdle(_uiThreadExecutors)
+
+        fun runRenderThreadToIdle() = runFakeThreadToIdle(_renderThreadExecutors)
+
+        fun runOnUiThread(block: () -> Unit) {
+            val previousThreadId = currentThreadId
+            currentThreadId = ThreadId.UI
+            block()
+            currentThreadId = previousThreadId
+        }
+
+        fun runOnRenderThread(block: () -> Unit) {
+            val previousThreadId = currentThreadId
+            currentThreadId = ThreadId.RENDER
+            block()
+            currentThreadId = previousThreadId
+        }
+
+        private fun runFakeThreadOnce(fakeThread: FakeScheduledExecutor): Boolean {
+            val previousThreadId = currentThreadId
+            currentThreadId = fakeThread.threadId
+            var ranAny = false
+            if (fakeThread.tasks.isNotEmpty()) {
+                fakeThread.tasks.removeAt(0).run()
+                ranAny = true
+            }
+            currentThreadId = previousThreadId
+            return ranAny
+        }
+
+        private fun runFakeThreadToIdle(fakeThread: FakeScheduledExecutor): Boolean {
+            val previousThreadId = currentThreadId
+            currentThreadId = fakeThread.threadId
+            var ranAny = false
+            while (fakeThread.tasks.isNotEmpty()) {
+                fakeThread.tasks.removeAt(0).run()
+                ranAny = true
+            }
+            currentThreadId = previousThreadId
+            return ranAny
+        }
+
+        private inner class FakeScheduledExecutor(val threadId: ThreadId) :
+            CanvasInProgressStrokesRenderHelperV33.ScheduledExecutor {
+            val tasks = mutableListOf<Runnable>()
+            /** Each element is a delayed runtime (in the [clock] time space) with its task. */
+            val delayedTasks = mutableListOf<Pair<Long, Runnable>>()
+
+            override fun onThread() = currentThreadId == threadId
+
+            override fun execute(command: Runnable) {
+                tasks.add(command)
+            }
+
+            override fun executeDelayed(
+                command: Runnable,
+                delayTime: Long,
+                delayTimeUnit: TimeUnit
+            ) {
+                if (delayTime == 0L) {
+                    execute(command)
+                } else {
+                    delayedTasks.add(
+                        Pair(clock.currentTimeMillis + delayTimeUnit.toMillis(delayTime), command)
+                    )
+                }
+            }
+
+            fun onNewTime(timeMillis: Long) {
+                val potentialTasks = delayedTasks.iterator()
+                for ((taskTimeMillis, task) in potentialTasks) {
+                    if (taskTimeMillis <= timeMillis) {
+                        potentialTasks.remove()
+                        tasks.add(task)
+                    }
+                }
+            }
+        }
+    }
+
+    internal class FakeClock(initialTimeMillis: Long, private var onNewTime: (Long) -> Unit) {
+        var currentTimeMillis = initialTimeMillis
+            set(value) {
+                field = value
+                onNewTime(field)
+            }
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokePoolTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokePoolTest.kt
new file mode 100644
index 0000000..ff7438f
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokePoolTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import androidx.ink.strokes.InProgressStroke
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+@SmallTest
+class InProgressStrokePoolTest {
+
+    @Test
+    fun obtain_whenCalledTwice_returnsDifferentInstances() {
+        val pool = InProgressStrokePool.create()
+
+        val first = pool.obtain()
+        val second = pool.obtain()
+
+        assertThat(second).isNotSameInstanceAs(first)
+    }
+
+    @Test
+    fun obtain_whenCalledAfterRecycle_returnsSameInstance() {
+        val pool = InProgressStrokePool.create()
+
+        val first = pool.obtain()
+        pool.recycle(first)
+        val second = pool.obtain()
+
+        assertThat(second).isSameInstanceAs(first)
+    }
+
+    @Test
+    fun trimToSize_whenNegative_throws() {
+        assertFailsWith<IllegalArgumentException> { InProgressStrokePool.create().trimToSize(-1) }
+    }
+
+    @Test
+    fun trimToSize_whenZero_obtainReturnsNewInstance() {
+        val pool = InProgressStrokePool.create()
+        val obtainedBeforeTrim = mutableSetOf<InProgressStroke>()
+        repeat(10) {
+            val instance = pool.obtain()
+            obtainedBeforeTrim.add(instance)
+            pool.recycle(instance)
+        }
+
+        pool.trimToSize(0)
+
+        assertThat(pool.obtain()).isNotIn(obtainedBeforeTrim)
+    }
+
+    @Test
+    fun trimToSize_whenLessThanCurrentPoolSize_obtainReturnsSameInstancesThenNewInstances() {
+        val pool = InProgressStrokePool.create()
+        val obtainedBeforeTrim = mutableSetOf<InProgressStroke>()
+        repeat(10) {
+            val instance = pool.obtain()
+            obtainedBeforeTrim.add(instance)
+        }
+        for (instance in obtainedBeforeTrim) {
+            pool.recycle(instance)
+        }
+
+        pool.trimToSize(3)
+
+        repeat(3) {
+            val shouldBeOld = pool.obtain()
+            assertThat(shouldBeOld).isIn(obtainedBeforeTrim)
+        }
+        val shouldBeNew = pool.obtain()
+        assertThat(shouldBeNew).isNotIn(obtainedBeforeTrim)
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokesManagerTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokesManagerTest.kt
new file mode 100644
index 0000000..01558de
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/InProgressStrokesManagerTest.kt
@@ -0,0 +1,1683 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import android.graphics.Path
+import android.os.Build
+import android.view.MotionEvent
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.authoring.latency.latencyDataEqual
+import androidx.ink.brush.Brush
+import androidx.ink.brush.BrushBehavior
+import androidx.ink.brush.BrushFamily
+import androidx.ink.brush.BrushTip
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.brush.InputToolType
+import androidx.ink.brush.StockBrushes
+import androidx.ink.geometry.MutableBox
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.StrokeInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import java.util.concurrent.TimeUnit
+import kotlin.test.fail
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.times
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+/**
+ * Unit tests for [InProgressStrokesManager].
+ *
+ * In production, there are two threads: the UI thread and the render thread. Client calls to
+ * [InProgressStrokesManager] happen on the UI thread. The manager interacts through the lower-level
+ * rendering system via the [InProgressStrokesRenderHelper], which makes callbacks into the manager
+ * on the render thread.
+ *
+ * In contrast, in these tests we have just one thread: the one that each test runs in. So the test
+ * setup functions configure the manager and the render helper to work on that single thread, either
+ * by running async requests synchronously, or by queueing those async requests for the test code to
+ * run at a specific time of the test.
+ */
+@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalLatencyDataApi::class)
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+@SdkSuppress(
+    minSdkVersion = Build.VERSION_CODES.N, // Mockito expects Java Stream
+    maxSdkVersion = Build.VERSION_CODES.TIRAMISU, // Mockito crash on Jetpack API 34 emulator
+)
+internal class InProgressStrokesManagerTest {
+    private val inProgressStrokesRenderHelper = mock<InProgressStrokesRenderHelper> {}
+
+    /**
+     * Returns an [InProgressStrokesManager] instance that runs callbacks synchronously on the
+     * test's single thread.
+     */
+    private fun makeSynchronousInProgressStrokesManager(
+        latencyDataRecorder: LatencyDataRecorder,
+        clock: FakeClock,
+        inProgressStrokePool: FakeInProgressStrokePool = FakeInProgressStrokePool(),
+    ): InProgressStrokesManager {
+        return InProgressStrokesManager(
+            inProgressStrokesRenderHelper,
+            // In production, the manager calls postOnAnimation/postToUiThread from the render
+            // thread to
+            // run callbacks on the UI thread. However, in these tests, the caller of these will be
+            // in the
+            // single test thread, and we will just run each callback synchronously.
+            postOnAnimation = Runnable::run,
+            postToUiThread = Runnable::run,
+            latencyDataCallback = { data: LatencyData -> latencyDataRecorder.record(data) },
+            getNanoTime = { clock.getNextTime() },
+            inProgressStrokePool = inProgressStrokePool,
+        )
+    }
+
+    /**
+     * Creates the [InProgressStrokesManager] under test in a way that can simulate its
+     * multi-threaded implementation in a single-threaded test. More complex test scenarios will
+     * require this approach, but simpler scenarios may be able to use
+     * [makeSynchronousInProgressStrokesManager].
+     *
+     * @return
+     *     1. The [InProgressStrokesManager].
+     *     2. The [AsyncRenderHelper], which can be used to run the render thread to idle.
+     *     3. A function to run the UI thread to the next frame.
+     */
+    private fun makeAsyncManager(
+        latencyDataRecorder: LatencyDataRecorder,
+        clock: FakeClock,
+        inProgressStrokePool: FakeInProgressStrokePool = FakeInProgressStrokePool(),
+    ): Triple<InProgressStrokesManager, FakeAsyncRenderHelper, () -> Boolean> {
+        lateinit var manager: InProgressStrokesManager
+        // Indirection due to an indirect circular dependency between the manager and the render
+        // helper.
+        val callback =
+            object : InProgressStrokesRenderHelper.Callback {
+                override fun onDraw() = manager.onDraw()
+
+                override fun onDrawComplete() = manager.onDrawComplete()
+
+                override fun reportEstimatedPixelPresentationTime(timeNanos: Long) =
+                    manager.reportEstimatedPixelPresentationTime(timeNanos)
+
+                override fun setCustomLatencyDataField(setter: (LatencyData, Long) -> Unit) =
+                    manager.setCustomLatencyDataField(setter)
+
+                override fun handOffAllLatencyData() = manager.handOffAllLatencyData()
+
+                override fun setPauseStrokeCohortHandoffs(paused: Boolean) =
+                    manager.setPauseStrokeCohortHandoffs(paused)
+
+                override fun onStrokeCohortHandoffToHwui(
+                    strokeCohort: Map<InProgressStrokeId, FinishedStroke>
+                ) = manager.onStrokeCohortHandoffToHwui(strokeCohort)
+
+                override fun onStrokeCohortHandoffToHwuiComplete() =
+                    manager.onStrokeCohortHandoffToHwuiComplete()
+            }
+        val renderHelper = FakeAsyncRenderHelper(callback, clock)
+        val uiThreadRunnables = mutableListOf<Runnable>()
+        val onAnimationRunnables = mutableListOf<Runnable>()
+        val runUiThreadToNextFrame = {
+            var ranAny = false
+            // Run through next onAnimation.
+            if (uiThreadRunnables.isNotEmpty() || onAnimationRunnables.isNotEmpty()) {
+                // uiThreadRunnables executing may add more to the list, or to onAnimationRunnables.
+                while (uiThreadRunnables.isNotEmpty()) {
+                    ranAny = true
+                    uiThreadRunnables.removeAt(0).run()
+                }
+                // Only run the onAnimationRunnables that are currently present, as a newly posted
+                // runnable
+                // would run at the next onAnimation.
+                if (onAnimationRunnables.isNotEmpty()) {
+                    ranAny = true
+                    val onAnimation = onAnimationRunnables.toList()
+                    onAnimationRunnables.clear()
+                    onAnimation.forEach(Runnable::run)
+                }
+            }
+            ranAny
+        }
+        manager =
+            InProgressStrokesManager(
+                renderHelper,
+                // In production, the manager calls postOnAnimation/postToUiThread from the render
+                // thread to
+                // run callbacks on the UI thread. However, in these tests, there is only a single
+                // test
+                // thread, so save the callbacks to run later to simulate the UI thread being
+                // scheduled.
+                postOnAnimation = { onAnimationRunnables.add(it) },
+                postToUiThread = { uiThreadRunnables.add(it) },
+                latencyDataCallback = { data: LatencyData -> latencyDataRecorder.record(data) },
+                getNanoTime = { clock.getNextTime() },
+                inProgressStrokePool = inProgressStrokePool,
+                blockingAwait = { latch, _, _ ->
+                    runToIdle(runUiThreadToNextFrame, renderHelper::runRenderThreadToIdle)
+                    // Expect that the latch will have counted down during the execution.
+                    latch.count == 0L
+                },
+            )
+        return Triple(manager, renderHelper, runUiThreadToNextFrame)
+    }
+
+    private fun runToIdle(
+        runUiThreadToIdle: () -> Boolean,
+        runRenderThreadToIdle: () -> Boolean,
+    ): Boolean {
+        var ranAny = false
+        while (runUiThreadToIdle() || runRenderThreadToIdle()) {
+            ranAny = true
+        }
+        return ranAny
+    }
+
+    private fun makeAsyncInProgressStrokesManager(
+        latencyDataRecorder: LatencyDataRecorder,
+        clock: FakeClock,
+        inProgressStrokePool: FakeInProgressStrokePool = FakeInProgressStrokePool(),
+    ): InProgressStrokesManager {
+        val uiThreadRunnables = mutableListOf<Runnable>()
+        val onAnimationRunnables = mutableListOf<Runnable>()
+        return InProgressStrokesManager(
+            inProgressStrokesRenderHelper,
+            // In production, the manager calls postOnAnimation/postToUiThread from the render
+            // thread to
+            // run callbacks on the UI thread. However, in these tests, there is only a single test
+            // thread, so save the callbacks to run later to simulate the UI thread being scheduled.
+            postOnAnimation = { onAnimationRunnables.add(it) },
+            postToUiThread = { uiThreadRunnables.add(it) },
+            latencyDataCallback = { data: LatencyData -> latencyDataRecorder.record(data) },
+            getNanoTime = { clock.getNextTime() },
+            inProgressStrokePool = inProgressStrokePool,
+            blockingAwait = { latch, _, _ ->
+                while (uiThreadRunnables.isNotEmpty() || onAnimationRunnables.isNotEmpty()) {
+                    // uiThreadRunnables executing may add more to the list, or ot
+                    // onAnimationRunnables.
+                    while (uiThreadRunnables.isNotEmpty()) {
+                        uiThreadRunnables.removeAt(0).run()
+                    }
+                    while (onAnimationRunnables.isNotEmpty()) {
+                        onAnimationRunnables.removeAt(0).run()
+                    }
+                    // onAnimationRunnables might have refilled uiThreadRunnables, so try those
+                    // again until
+                    // both lists are empty.
+                }
+                // Expect that the latch will have counted down during the
+                latch.count == 0L
+            },
+        )
+    }
+
+    /**
+     * Sets up the mock [InProgressStrokesRenderHelper] to synchronously call back into the manager
+     * on the test's single thread.
+     *
+     * In production, the manager calls helper.requestDraw from the UI thread, which eventually
+     * results in some work in the helper on the render thread. As part of this work, the helper
+     * makes a synchronous sequence of calls into the manager: onDraw, onDrawComplete, and various
+     * latency tracking calls for the end of drawing. Here we set up the mock instance to run these
+     * calls immediately and synchronously on the test's single thread.
+     */
+    private fun setUpMockInProgressStrokesRenderHelperForSynchronousOperation(
+        manager: InProgressStrokesManager,
+        clock: FakeClock,
+    ) {
+        whenever(inProgressStrokesRenderHelper.requestDraw()).then {
+            manager.onDraw()
+            manager.onDrawComplete()
+            manager.setCustomLatencyDataField({ data: LatencyData, timeNanos: Long ->
+                data.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = timeNanos
+            })
+            manager.reportEstimatedPixelPresentationTime(clock.getNextTime())
+            manager.handOffAllLatencyData()
+        }
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForPenDown() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        // Arbitrary start time; there are no checks for consistency between MotionEvents and the
+        // clock.
+        val clock = FakeClock(777_000L)
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms. Note that LatencyData results will show this as
+        // 321_000_000ns.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.START
+                        eventAction = LatencyData.EventAction.DOWN
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 321_000_000
+                        // The clock ticked once to record this time.
+                        strokesViewGetsAction = 777_001
+                        // And once more for this one.
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_002
+                        // And once more for this one.
+                        estimatedPixelPresentationTime = 777_003
+                    }
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForMove() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        // We already checked the reported LatencyData in a previous test. The clock ticked once.
+        latencyDataRecorder.recordedData.clear()
+        // Reset the fake clock, since we only care about latency reports from here onward. The
+        // specific
+        // time doesn't matter; there are no checks for consistency between MotionEvents and the
+        // clock.
+        clock.timeNanos = 777_000L
+
+        // Now move the pen at t=325ms and t=329ms.
+        val moveEvent1 = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        val moveEvent2 = MotionEvent.obtain(321, 329, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+
+        manager.addToStroke(moveEvent1, 0, inProgressStrokeId, null)
+        manager.addToStroke(moveEvent2, 0, inProgressStrokeId, null)
+
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 325_000_000
+                        // The clock ticked once to record this time.
+                        strokesViewGetsAction = 777_001
+                        // And twice for this one - once on the UI thread and once on the render
+                        // thread.
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        // And once more for this one.
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 329_000_000
+                        // The clock ticked again to get this time.
+                        strokesViewGetsAction = 777_005
+                        // And twice for this one - once on the UI thread and once on the render
+                        // thread.
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_007
+                        // And once more for this one.
+                        estimatedPixelPresentationTime = 777_008
+                    },
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForBatchedMove() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        // We already checked the reported LatencyData in a previous test. The clock ticked once.
+        latencyDataRecorder.recordedData.clear()
+        // Reset the fake clock, since we only care about latency reports from here onward. The
+        // specific
+        // time doesn't matter; there are no checks for consistency between MotionEvents and the
+        // clock.
+        clock.timeNanos = 777_000L
+
+        // Now move the pen at t=325ms and t=329ms. The two events got batched together.
+        val moveEvent = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        moveEvent.addBatch(329, 14f, 24f, 1f, 1f, 0)
+
+        manager.addToStroke(moveEvent, 0, inProgressStrokeId, null)
+
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    // Since both MOVE events were sent in one call, they were processed together.
+                    // So there
+                    // was just one clock tick to get the draw call finish time, and one other for
+                    // the
+                    // estimated pixel presentation time.
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 325_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 329_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForPredictedMove() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        // We already checked the reported LatencyData in a previous test.
+        latencyDataRecorder.recordedData.clear()
+        // Reset the fake clock, since we only care about latency reports from here onward. The
+        // specific
+        // time doesn't matter; there are no checks for consistency between MotionEvents and the
+        // clock.
+        clock.timeNanos = 777_000L
+
+        // Now move the pen at t=325ms. The platform predicted events for t=329ms and t=333ms.
+        val realMoveEvent = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        val predictedMoveEvent = MotionEvent.obtain(321, 329, MotionEvent.ACTION_MOVE, 16f, 26f, 0)
+        predictedMoveEvent.addBatch(333, 18f, 28f, 1f, 1f, 0)
+
+        manager.addToStroke(realMoveEvent, 0, inProgressStrokeId, predictedMoveEvent)
+
+        // Now we should have latency data for all three input events.
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    // Since all MOVE events (one real, two predicted) were sent in one call, they
+                    // were
+                    // processed together. So there was just one clock tick to get the draw call
+                    // finish time,
+                    // and one other for the estimated pixel presentation time.
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 325_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 329_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 333_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForBatchedAndPredictedMove() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        // We already checked the reported LatencyData in a previous test.
+        latencyDataRecorder.recordedData.clear()
+        // Reset the fake clock, since we only care about latency reports from here onward. The
+        // specific
+        // time doesn't matter; there are no checks for consistency between MotionEvents and the
+        // clock.
+        clock.timeNanos = 777_000L
+
+        // Now move the pen at t=325ms and t=329ms. The two events got batched together. The
+        // platform
+        // also predicted events for t=333ms and t=327ms.
+        val realMoveEvent = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        realMoveEvent.addBatch(329, 14f, 24f, 1f, 1f, 0)
+        val predictedMoveEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_MOVE, 16f, 26f, 0)
+        predictedMoveEvent.addBatch(337, 18f, 28f, 1f, 1f, 0)
+
+        manager.addToStroke(realMoveEvent, 0, inProgressStrokeId, predictedMoveEvent)
+
+        // Now we should have latency data for all three input events.
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    // Since all MOVE events (two real, two predicted) were sent in one call, they
+                    // were
+                    // processed together. So there were just two clock ticks to get the draw call
+                    // finish
+                    // time, and one other for the estimated pixel presentation time.
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 325_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 329_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 333_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 337_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForAllPredictedInputsEvenWhenOverwrittenByRealInputs() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        // We already checked the reported LatencyData in a previous test.
+        latencyDataRecorder.recordedData.clear()
+        // Reset the fake clock, since we only care about latency reports from here onward. The
+        // specific
+        // time doesn't matter; there are no checks for consistency between MotionEvents and the
+        // clock.
+        clock.timeNanos = 777_000L
+
+        // Now move the pen. There are three real inputs: two in one batch and then another. There
+        // are
+        // also predicted inputs along with both batches of real inputs.
+        val realMoveEvent1 = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        realMoveEvent1.addBatch(329, 14f, 24f, 1f, 1f, 0)
+        val predictedMoveEvent1 = MotionEvent.obtain(321, 333, MotionEvent.ACTION_MOVE, 16f, 26f, 0)
+        predictedMoveEvent1.addBatch(337, 18f, 28f, 1f, 1f, 0)
+        // The prediction was correct: the next real input exactly matches the last prediction.
+        val realMoveEvent2 = MotionEvent.obtain(321, 337, MotionEvent.ACTION_MOVE, 18f, 28f, 0)
+        val predictedMoveEvent2 = MotionEvent.obtain(321, 341, MotionEvent.ACTION_MOVE, 20f, 30f, 0)
+
+        manager.addToStroke(realMoveEvent1, 0, inProgressStrokeId, predictedMoveEvent1)
+        manager.addToStroke(realMoveEvent2, 0, inProgressStrokeId, predictedMoveEvent2)
+
+        // Now we should have latency data for all the input events, even the old predictions that
+        // were
+        // superseded (in rendering) by the newest real input.
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    // The first addToStroke (two real inputs and two predicted) led to two clock
+                    // ticks: one
+                    // for the draw call finish time and one for the estimated pixel presentation
+                    // time.
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 325_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 329_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 0
+                        osDetectsEvent = 333_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 2
+                        batchIndex = 1
+                        osDetectsEvent = 337_000_000
+                        strokesViewGetsAction = 777_001
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_003
+                        estimatedPixelPresentationTime = 777_004
+                    },
+                    // The second addToStroke (one real input and one predicted) led to two more
+                    // clock ticks.
+                    // The fact that the real input is the same as the last prediction doesn't
+                    // matter at all
+                    // for latency reporting.
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.ADD
+                        eventAction = LatencyData.EventAction.MOVE
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 337_000_000
+                        strokesViewGetsAction = 777_005
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_007
+                        estimatedPixelPresentationTime = 777_008
+                    },
+                    LatencyData().apply {
+                        strokeId = inProgressStrokeId
+                        strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                        eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 341_000_000
+                        strokesViewGetsAction = 777_005
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 777_007
+                        estimatedPixelPresentationTime = 777_008
+                    },
+                )
+            )
+    }
+
+    @Test
+    fun latencyDataCallback_getsLatencyDataForPenUp() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        // Set the pen down at t=321ms.
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        // Now move the pen at t=325ms and t=329ms.
+        val moveEvent1 = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        val moveEvent2 = MotionEvent.obtain(321, 329, MotionEvent.ACTION_MOVE, 12f, 22f, 0)
+        manager.addToStroke(moveEvent1, 0, inProgressStrokeId, null)
+        manager.addToStroke(moveEvent2, 0, inProgressStrokeId, null)
+
+        // We already checked the reported LatencyData in a previous test.
+        latencyDataRecorder.recordedData.clear()
+
+        clock.timeNanos = 334_000_000
+        // Finally, lift up the pen at t=333ms.
+        val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+
+        manager.finishStroke(upEvent, 0, inProgressStrokeId)
+
+        assertThat(latencyDataRecorder.recordedData)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(
+                listOf(
+                    LatencyData().apply {
+                        eventAction = LatencyData.EventAction.UP
+                        strokeAction = LatencyData.StrokeAction.FINISH
+                        strokeId = inProgressStrokeId
+                        batchSize = 1
+                        batchIndex = 0
+                        osDetectsEvent = 333_000_000
+                        // The clock ticked once to get this time.
+                        strokesViewGetsAction = 334_000_001
+                        // And twice for this one - once on the UI thread and once on the render
+                        // thread.
+                        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 334_000_003
+                        // And once more for this one.
+                        estimatedPixelPresentationTime = 334_000_004
+                    }
+                )
+            )
+    }
+
+    @Test
+    fun startStroke_whenContentRetained_shouldDrawWithFiniteModifiedRegion() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        whenever(inProgressStrokesRenderHelper.contentsPreservedBetweenDraws).thenReturn(true)
+
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        @Suppress("UNUSED_VARIABLE")
+        val unused = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        val modifiedRegionCaptor = argumentCaptor<MutableBox>()
+        verify(inProgressStrokesRenderHelper)
+            .prepareToDrawInModifiedRegion(modifiedRegionCaptor.capture())
+        assertThat(modifiedRegionCaptor.firstValue.width).isFinite()
+        assertThat(modifiedRegionCaptor.firstValue.height).isFinite()
+        verify(inProgressStrokesRenderHelper).drawInModifiedRegion(any<InProgressStroke>(), any())
+        verify(inProgressStrokesRenderHelper).afterDrawInModifiedRegion()
+    }
+
+    @Test
+    fun startStroke_whenContentNotRetained_shouldDrawWithInfiniteModifiedRegion() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        whenever(inProgressStrokesRenderHelper.contentsPreservedBetweenDraws).thenReturn(false)
+
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        @Suppress("UNUSED_VARIABLE")
+        val unused = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        val modifiedRegionCaptor = argumentCaptor<MutableBox>()
+        verify(inProgressStrokesRenderHelper)
+            .prepareToDrawInModifiedRegion(modifiedRegionCaptor.capture())
+        assertThat(modifiedRegionCaptor.firstValue.xMin).isNegativeInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.xMax).isPositiveInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.yMin).isNegativeInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.yMax).isPositiveInfinity()
+        verify(inProgressStrokesRenderHelper).drawInModifiedRegion(any<InProgressStroke>(), any())
+        verify(inProgressStrokesRenderHelper).afterDrawInModifiedRegion()
+    }
+
+    @Test
+    fun addToStroke_whenInSameLocationAndContentNotRetained_shouldRedrawWithInfiniteModifiedRegion() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        whenever(inProgressStrokesRenderHelper.contentsPreservedBetweenDraws).thenReturn(false)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        // The specifics of this are validated in a test focused on startStroke.
+        verify(inProgressStrokesRenderHelper).prepareToDrawInModifiedRegion(any())
+        verify(inProgressStrokesRenderHelper).drawInModifiedRegion(any<InProgressStroke>(), any())
+        verify(inProgressStrokesRenderHelper).afterDrawInModifiedRegion()
+
+        val moveEvent = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        manager.addToStroke(moveEvent, 0, inProgressStrokeId, null)
+
+        // Each being called a second time - the first time was from startStroke.
+        val modifiedRegionCaptor = argumentCaptor<MutableBox>()
+        verify(inProgressStrokesRenderHelper, times(2))
+            .prepareToDrawInModifiedRegion(modifiedRegionCaptor.capture())
+        assertThat(modifiedRegionCaptor.firstValue.xMin).isNegativeInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.xMax).isPositiveInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.yMin).isNegativeInfinity()
+        assertThat(modifiedRegionCaptor.firstValue.yMax).isPositiveInfinity()
+        verify(inProgressStrokesRenderHelper, times(2))
+            .drawInModifiedRegion(any<InProgressStroke>(), any())
+        verify(inProgressStrokesRenderHelper, times(2)).afterDrawInModifiedRegion()
+    }
+
+    @Test
+    fun onHandoff_whenContentRetained_shouldCallRenderHelperClear() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        whenever(inProgressStrokesRenderHelper.contentsPreservedBetweenDraws).thenReturn(true)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+        manager.finishStroke(upEvent, 0, inProgressStrokeId)
+        manager.onStrokeCohortHandoffToHwuiComplete() // Unpause input processing to finish handoff.
+
+        verify(inProgressStrokesRenderHelper).clear()
+    }
+
+    @Test
+    fun onHandoff_whenContentNotRetained_shouldNotCallRenderHelperClear() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        whenever(inProgressStrokesRenderHelper.contentsPreservedBetweenDraws).thenReturn(false)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+        manager.finishStroke(upEvent, 0, inProgressStrokeId)
+        manager.onStrokeCohortHandoffToHwuiComplete() // Unpause input processing to finish handoff.
+
+        verify(inProgressStrokesRenderHelper, never()).clear()
+    }
+
+    @Test
+    fun startStroke_shouldObtainInProgressStroke() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        val manager =
+            makeSynchronousInProgressStrokesManager(
+                latencyDataRecorder,
+                clock,
+                inProgressStrokePool
+            )
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        @Suppress("UNUSED_VARIABLE")
+        val unused = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        assertThat(inProgressStrokePool.obtainCount).isEqualTo(1)
+        assertThat(inProgressStrokePool.recycleCount).isEqualTo(0)
+        assertThat(inProgressStrokePool.trimToSizeLastValue).isNull()
+    }
+
+    @Test
+    fun onHandoff_shouldRecycleInProgressStroke() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        val manager =
+            makeSynchronousInProgressStrokesManager(
+                latencyDataRecorder,
+                clock,
+                inProgressStrokePool
+            )
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+        manager.finishStroke(upEvent, 0, inProgressStrokeId)
+        manager.onStrokeCohortHandoffToHwuiComplete() // Unpause input processing to finish handoff.
+
+        assertThat(inProgressStrokePool.obtainCount).isEqualTo(1)
+        assertThat(inProgressStrokePool.recycleCount).isEqualTo(1)
+        assertThat(inProgressStrokePool.trimToSizeLastValue).isEqualTo(1)
+    }
+
+    @Test
+    fun onHandoff_afterMultipleHandoffs_shouldTrimInProgressStrokePoolToMaxCohortSize() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        val manager =
+            makeSynchronousInProgressStrokesManager(
+                latencyDataRecorder,
+                clock,
+                inProgressStrokePool
+            )
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val cohortSizesForHandoffs = listOf(2, 9, 3, 8, 4, 7, 5, 6, 1, 1, 1, 1, 1, 1)
+        val maxOfLast10CohortSizes = listOf(2, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 7)
+        check(maxOfLast10CohortSizes.size == cohortSizesForHandoffs.size)
+
+        for (handoffIndex in cohortSizesForHandoffs.indices) {
+            val cohortSize = cohortSizesForHandoffs[handoffIndex]
+            val cohortStrokeIds = mutableListOf<InProgressStrokeId>()
+            repeat(cohortSize) {
+                val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+                cohortStrokeIds.add(
+                    manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+                )
+                assertThat(inProgressStrokePool.obtainCount).isEqualTo(it + 1) // it is 0-based
+            }
+            assertThat(inProgressStrokePool.obtainCount).isEqualTo(cohortSize)
+            assertThat(inProgressStrokePool.recycleCount).isEqualTo(0)
+            assertThat(inProgressStrokePool.trimToSizeLastValue).isNull()
+            for (strokeId in cohortStrokeIds) {
+                // None are recycled until the last one is finished and the entire cohort is handed
+                // off
+                // together.
+                assertThat(inProgressStrokePool.recycleCount).isEqualTo(0)
+                val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+                manager.finishStroke(upEvent, 0, strokeId)
+            }
+            manager.onStrokeCohortHandoffToHwuiComplete() // Unpause input processing to finish
+            // handoff.
+            assertThat(inProgressStrokePool.obtainCount).isEqualTo(cohortSize)
+            assertThat(inProgressStrokePool.recycleCount).isEqualTo(cohortSize)
+            assertThat(inProgressStrokePool.trimToSizeLastValue)
+                .isEqualTo(maxOfLast10CohortSizes[handoffIndex])
+            // Check obtain/recycle counts and trimToSize separately for each cohort.
+            inProgressStrokePool.resetTestData()
+        }
+    }
+
+    @Test
+    fun finishStroke_shouldCallStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(latencyDataRecorder, clock)
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val strokeId = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        val upEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_UP,
+                12f,
+                22f,
+                0
+            )
+        manager.finishStroke(upEvent, 0, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+
+        assertThat(finishedStrokes).containsExactly(strokeId)
+    }
+
+    @Test
+    fun onAllStrokesFinished_getsFinishedStrokeWithAllInputs_motionEventApi() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(latencyDataRecorder, clock)
+        val finishedStrokeIds = mutableListOf<InProgressStrokeId>()
+        val finishedStrokes = mutableListOf<FinishedStroke>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokeIds.addAll(strokes.keys)
+                    finishedStrokes.addAll(strokes.values)
+                }
+            }
+        )
+
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val strokeId = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        clock.advanceByMillis(1000)
+        val moveEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_MOVE,
+                30f,
+                40f,
+                0
+            )
+        manager.addToStroke(moveEvent, 0, strokeId, null)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        clock.advanceByMillis(1000)
+        val upEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_UP,
+                50f,
+                60f,
+                0
+            )
+        manager.finishStroke(upEvent, 0, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+
+        assertThat(finishedStrokeIds).containsExactly(strokeId)
+        assertThat(finishedStrokes).hasSize(1)
+        val stroke = finishedStrokes[0].stroke
+        assertThat(stroke.inputs.size).isEqualTo(3)
+        assertThat(stroke.inputs[0])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 10f,
+                    y = 20f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 0
+                )
+            )
+        assertThat(stroke.inputs[1])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 30f,
+                    y = 40f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 1000,
+                )
+            )
+        assertThat(stroke.inputs[2])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 50f,
+                    y = 60f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 2000,
+                )
+            )
+        // The MotionEvent API records latency data at each step.
+        assertThat(latencyDataRecorder.recordedData).hasSize(3)
+    }
+
+    @Test
+    fun onAllStrokesFinished_getsFinishedStrokeWithAllInputs_strokeInputApi() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(latencyDataRecorder, clock)
+        val finishedStrokeIds = mutableListOf<InProgressStrokeId>()
+        val finishedStrokes = mutableListOf<FinishedStroke>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokeIds.addAll(strokes.keys)
+                    finishedStrokes.addAll(strokes.values)
+                }
+            }
+        )
+
+        val downInput =
+            StrokeInput.create(
+                x = 10f,
+                y = 20f,
+                toolType = InputToolType.TOUCH,
+                elapsedTimeMillis = 0
+            )
+        val strokeId = manager.startStroke(downInput, makeBrush())
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        clock.advanceByMillis(1000)
+        val moveInputs =
+            MutableStrokeInputBatch()
+                .apply {
+                    addOrThrow(
+                        StrokeInput.create(
+                            x = 30f,
+                            y = 40f,
+                            toolType = InputToolType.TOUCH,
+                            elapsedTimeMillis = 1000,
+                        )
+                    )
+                }
+                .asImmutable()
+        manager.addToStroke(moveInputs, strokeId, ImmutableStrokeInputBatch.EMPTY)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        clock.advanceByMillis(1000)
+        val upInput =
+            StrokeInput.create(
+                x = 50f,
+                y = 60f,
+                toolType = InputToolType.TOUCH,
+                elapsedTimeMillis = 2000
+            )
+        manager.finishStroke(upInput, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+
+        assertThat(finishedStrokeIds).containsExactly(strokeId)
+        assertThat(finishedStrokes).hasSize(1)
+        val stroke = finishedStrokes[0].stroke
+        assertThat(stroke.inputs.size).isEqualTo(3)
+        assertThat(stroke.inputs[0])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 10f,
+                    y = 20f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 0
+                )
+            )
+        assertThat(stroke.inputs[1])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 30f,
+                    y = 40f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 1000,
+                )
+            )
+        assertThat(stroke.inputs[2])
+            .isEqualTo(
+                StrokeInput.create(
+                    x = 50f,
+                    y = 60f,
+                    toolType = InputToolType.TOUCH,
+                    elapsedTimeMillis = 2000,
+                )
+            )
+        // The StrokeInput[Batch] API doesn't record latency data.
+        assertThat(latencyDataRecorder.recordedData).isEmpty()
+    }
+
+    @Test
+    fun startStroke_withNonInvertibleStrokeToWorldTransform_throwsException() {
+        val clock = FakeClock()
+        val (manager, _, _) = makeAsyncManager(LatencyDataRecorder(), clock)
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+
+        val error =
+            assertThrows(IllegalArgumentException::class.java) {
+                manager.startStroke(
+                    downEvent,
+                    pointerIndex = 0,
+                    motionEventToWorldTransform = Matrix(),
+                    strokeToWorldTransform = Matrix().apply { setScale(0f, 0f) },
+                    brush = makeBrush(),
+                    strokeUnitLengthCm = 0f,
+                )
+            }
+        assertThat(error).hasMessageThat().contains("strokeToWorldTransform must be invertible")
+    }
+
+    @Test
+    fun startStroke_shouldCombineTransformsCorrectly() {
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(LatencyDataRecorder(), clock)
+        val finishedStrokes = mutableListOf<FinishedStroke>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.values)
+                }
+            }
+        )
+
+        val motionEventToWorldTransform = Matrix().apply { setScale(2f, 2f) }
+        val strokeToWorldTransform = Matrix().apply { setTranslate(1f, 3f) }
+
+        // Create a stroke at (10, 20) in MotionEvent space.
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val strokeId =
+            manager.startStroke(
+                downEvent,
+                0,
+                motionEventToWorldTransform,
+                strokeToWorldTransform,
+                makeBrush(),
+                strokeUnitLengthCm = 0f,
+            )
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        val upEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_UP,
+                10f,
+                20f,
+                0
+            )
+        manager.finishStroke(upEvent, 0, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+
+        assertThat(finishedStrokes).hasSize(1)
+        assertThat(finishedStrokes[0].stroke).isNotNull()
+
+        // Given the above transforms, the stroke should be at (20, 40) in world space, and at (19,
+        // 37)
+        // in stroke space.
+        val stroke = checkNotNull(finishedStrokes[0].stroke)
+        assertThat(stroke.inputs.get(0).x).isEqualTo(19f)
+        assertThat(stroke.inputs.get(0).y).isEqualTo(37f)
+
+        assertThat(finishedStrokes[0].strokeToViewTransform)
+            .isEqualTo(
+                Matrix().apply {
+                    setTranslate(1f, 3f)
+                    postScale(0.5f, 0.5f)
+                }
+            )
+    }
+
+    @Test
+    fun finishStroke_whenHandoffsPaused_shouldNotCallStrokesFinishedListenerUntilUnpaused() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(latencyDataRecorder, clock)
+        manager.setPauseStrokeCohortHandoffs(true)
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val strokeId = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        val upEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_UP,
+                12f,
+                22f,
+                0
+            )
+        manager.finishStroke(upEvent, 0, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        assertThat(finishedStrokes).isEmpty()
+
+        manager.setPauseStrokeCohortHandoffs(false)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        assertThat(finishedStrokes).containsExactly(strokeId)
+    }
+
+    @Test
+    fun finishStroke_withTimeSinceBrushBehavior_doesNotHandOffUntilBehaviorFinishes() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val (manager, renderHelper, runUiThreadToEndOfFrame) =
+            makeAsyncManager(latencyDataRecorder, clock)
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        // Create a brush with a time-since behavior that takes 250ms to complete.
+        val behavior =
+            BrushBehavior(
+                source = BrushBehavior.Source.TIME_SINCE_INPUT_IN_SECONDS,
+                target = BrushBehavior.Target.SIZE_MULTIPLIER,
+                sourceValueRangeLowerBound = 0f,
+                sourceValueRangeUpperBound = 0.25f,
+                targetModifierRangeLowerBound = 1.25f,
+                targetModifierRangeUpperBound = 1f,
+            )
+        val brush =
+            Brush(
+                family = BrushFamily(BrushTip(behaviors = listOf(behavior))),
+                size = 10f,
+                epsilon = 0.1f,
+            )
+
+        // Start a new stroke with the above brush.
+        val downTime = clock.getNextMillisTime()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val strokeId = manager.startStroke(downEvent, 0, Matrix(), Matrix(), brush, 0f)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+
+        // Finish inputs for the stroke. Because the stroke has a time-since behavior that hasn't
+        // yet
+        // completed, the stroke should not be dry yet.
+        val upEvent =
+            MotionEvent.obtain(
+                downTime,
+                clock.getNextMillisTime(),
+                MotionEvent.ACTION_UP,
+                12f,
+                22f,
+                0
+            )
+        manager.finishStroke(upEvent, 0, strokeId)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        assertThat(finishedStrokes).isEmpty()
+
+        // The time-since behavior takes 250ms to complete, so after 150ms, it should still not be
+        // done
+        // yet.
+        clock.advanceByMillis(150)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        assertThat(finishedStrokes).isEmpty()
+
+        // After a second 150ms, the 250ms behavior should be complete and the stroke should now be
+        // finished drying.
+        clock.advanceByMillis(150)
+        renderHelper.runRenderThreadToIdle()
+        runUiThreadToEndOfFrame()
+        assertThat(finishedStrokes).containsExactly(strokeId)
+    }
+
+    @Test
+    fun cancelStroke_shouldNotCallStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock()
+        val manager = makeSynchronousInProgressStrokesManager(latencyDataRecorder, clock)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    fail("Should never be called")
+                }
+            }
+        )
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val moveEvent = MotionEvent.obtain(321, 325, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        manager.addToStroke(moveEvent, 0, inProgressStrokeId, null)
+        val cancelEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_CANCEL, 12f, 22f, 0)
+        manager.cancelStroke(inProgressStrokeId, cancelEvent)
+    }
+
+    @Test
+    fun flush_whenNoStrokesInProgress_returnsWithoutCallingStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        whenever(inProgressStrokesRenderHelper.supportsFlush).thenReturn(true)
+
+        val manager =
+            makeAsyncInProgressStrokesManager(latencyDataRecorder, clock, inProgressStrokePool)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    fail("Expected no callbacks to this function.")
+                }
+            }
+        )
+
+        assertThat(manager.flush(1000, TimeUnit.MILLISECONDS, cancelAllInProgress = false)).isTrue()
+    }
+
+    @Test
+    fun flush_whenUnfinishedStrokesFinished_shouldFinishAllAndCallStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        whenever(inProgressStrokesRenderHelper.supportsFlush).thenReturn(true)
+        val manager =
+            makeAsyncInProgressStrokesManager(latencyDataRecorder, clock, inProgressStrokePool)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId1 =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val inProgressStrokeId2 =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        assertThat(manager.flush(1000, TimeUnit.MILLISECONDS, cancelAllInProgress = false)).isTrue()
+
+        assertThat(finishedStrokes).containsExactly(inProgressStrokeId1, inProgressStrokeId2)
+    }
+
+    @Test
+    fun flush_whenUnfinishedStrokesCanceled_shouldCancelAllAndNotCallStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        whenever(inProgressStrokesRenderHelper.supportsFlush).thenReturn(true)
+        val manager =
+            makeAsyncInProgressStrokesManager(latencyDataRecorder, clock, inProgressStrokePool)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        @Suppress("UNUSED_VARIABLE")
+        val unused1 = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        @Suppress("UNUSED_VARIABLE")
+        val unused2 = manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+
+        assertThat(manager.flush(1000, TimeUnit.MILLISECONDS, cancelAllInProgress = true)).isTrue()
+
+        assertThat(finishedStrokes).isEmpty()
+    }
+
+    @Test
+    fun flush_whenFinishedStrokesAreDebouncedAndPaused_shouldCallStrokesFinishedListener() {
+        val latencyDataRecorder = LatencyDataRecorder()
+        val clock = FakeClock().apply { timeNanos = 334_000_000 }
+        val inProgressStrokePool = FakeInProgressStrokePool()
+        whenever(inProgressStrokesRenderHelper.supportsFlush).thenReturn(true)
+        whenever(inProgressStrokesRenderHelper.supportsDebounce).thenReturn(true)
+        val manager =
+            makeAsyncInProgressStrokesManager(latencyDataRecorder, clock, inProgressStrokePool)
+        setUpMockInProgressStrokesRenderHelperForSynchronousOperation(manager, clock)
+        manager.setHandoffDebounceTimeMs(5000)
+        manager.setPauseStrokeCohortHandoffs(true)
+
+        val finishedStrokes = mutableListOf<InProgressStrokeId>()
+        manager.addListener(
+            object : InProgressStrokesManager.Listener {
+                override fun onAllStrokesFinished(
+                    strokes: Map<InProgressStrokeId, FinishedStroke>
+                ) {
+                    finishedStrokes.addAll(strokes.keys)
+                }
+            }
+        )
+
+        val downEvent = MotionEvent.obtain(321, 321, MotionEvent.ACTION_DOWN, 10f, 20f, 0)
+        val inProgressStrokeId1 =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val inProgressStrokeId2 =
+            manager.startStroke(downEvent, 0, Matrix(), Matrix(), makeBrush(), 0f)
+        val upEvent = MotionEvent.obtain(321, 333, MotionEvent.ACTION_UP, 12f, 22f, 0)
+        manager.finishStroke(upEvent, 0, inProgressStrokeId1)
+        manager.finishStroke(upEvent, 0, inProgressStrokeId2)
+
+        // These strokes aren't still in progress, they just haven't been handed off yet, so they
+        // shouldn't be canceled.
+        assertThat(manager.flush(1000, TimeUnit.MILLISECONDS, cancelAllInProgress = true)).isTrue()
+
+        assertThat(finishedStrokes).containsExactly(inProgressStrokeId1, inProgressStrokeId2)
+    }
+
+    private fun makeBrush() = Brush(family = StockBrushes.markerLatest, size = 10f, epsilon = 0.1f)
+}
+
+private class FakeClock(var timeNanos: Long = 0L) {
+    /** Increments `timeNanos` by 1 nanosecond and returns it. */
+    fun getNextTime(): Long {
+        timeNanos += 1L
+        return timeNanos
+    }
+
+    fun getNextMillisTime() = getNextTime() / 1_000_000
+
+    fun advanceByMillis(durationMillis: Long) {
+        timeNanos += durationMillis * 1_000_000
+    }
+}
+
+@OptIn(ExperimentalLatencyDataApi::class)
+private class LatencyDataRecorder() {
+    val recordedData = mutableListOf<LatencyData>()
+
+    fun record(data: LatencyData) {
+        val copy =
+            LatencyData().apply {
+                eventAction = data.eventAction
+                strokeAction = data.strokeAction
+                strokeId = data.strokeId
+                batchSize = data.batchSize
+                batchIndex = data.batchIndex
+                osDetectsEvent = data.osDetectsEvent
+                strokesViewGetsAction = data.strokesViewGetsAction
+                strokesViewFinishesDrawCalls = data.strokesViewFinishesDrawCalls
+                estimatedPixelPresentationTime = data.estimatedPixelPresentationTime
+                canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls =
+                    data.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls
+                hwuiInProgressStrokesRenderHelperData.finishesDrawCalls =
+                    data.hwuiInProgressStrokesRenderHelperData.finishesDrawCalls
+            }
+        recordedData.add(copy)
+    }
+}
+
+private class FakeInProgressStrokePool : InProgressStrokePool {
+    private val real = InProgressStrokePool.create()
+    var obtainCount = 0
+        private set
+
+    var recycleCount = 0
+        private set
+
+    var trimToSizeLastValue: Int? = null
+        private set
+
+    fun resetTestData() {
+        obtainCount = 0
+        recycleCount = 0
+        trimToSizeLastValue = null
+    }
+
+    override fun obtain(): InProgressStroke {
+        obtainCount++
+        return real.obtain()
+    }
+
+    override fun recycle(inProgressStroke: InProgressStroke) {
+        recycleCount++
+        real.recycle(inProgressStroke)
+    }
+
+    override fun trimToSize(maxSize: Int) {
+        trimToSizeLastValue = maxSize
+        real.trimToSize(maxSize)
+    }
+}
+
+/**
+ * A fake for [InProgressStrokesRenderHelper] which simulates its typically multi-threaded nature in
+ * a single-threaded test by providing hooks to run the queued "render thread" jobs.
+ */
+@OptIn(ExperimentalLatencyDataApi::class)
+private class FakeAsyncRenderHelper(
+    private val callback: InProgressStrokesRenderHelper.Callback,
+    private val clock: FakeClock,
+    override val contentsPreservedBetweenDraws: Boolean = true,
+    override val supportsDebounce: Boolean = true,
+    override val supportsFlush: Boolean = true,
+) : InProgressStrokesRenderHelper {
+    private var drawRequestCount = 0
+    private var onRenderThread = false
+    override var maskPath: Path? = null
+
+    fun runRenderThreadToIdle(): Boolean {
+        var ranAny = false
+        onRenderThread = true
+        while (drawRequestCount > 0) {
+            drawRequestCount--
+            ranAny = true
+            callback.onDraw()
+            callback.onDrawComplete()
+            callback.setCustomLatencyDataField { data: LatencyData, timeNanos: Long ->
+                data.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = timeNanos
+            }
+            callback.reportEstimatedPixelPresentationTime(clock.getNextTime())
+            callback.handOffAllLatencyData()
+        }
+        onRenderThread = false
+        return ranAny
+    }
+
+    override fun assertOnRenderThread() {
+        check(onRenderThread)
+    }
+
+    override fun requestDraw() {
+        check(!onRenderThread)
+        drawRequestCount++
+    }
+
+    override fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox) {
+        assertOnRenderThread()
+    }
+
+    override fun drawInModifiedRegion(
+        inProgressStroke: InProgressStroke,
+        strokeToMainViewTransform: Matrix,
+    ) {
+        assertOnRenderThread()
+    }
+
+    override fun afterDrawInModifiedRegion() {
+        assertOnRenderThread()
+    }
+
+    override fun clear() {
+        assertOnRenderThread()
+    }
+
+    override fun requestStrokeCohortHandoffToHwui(
+        handingOff: Map<InProgressStrokeId, FinishedStroke>
+    ) {
+        check(!onRenderThread)
+        callback.onStrokeCohortHandoffToHwui(handingOff)
+        callback.onStrokeCohortHandoffToHwuiComplete()
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/MutableBoxTransformTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/MutableBoxTransformTest.kt
new file mode 100644
index 0000000..7142a21
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/MutableBoxTransformTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import androidx.ink.geometry.ImmutableVec
+import androidx.ink.geometry.MutableBox
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class MutableBoxTransformTest {
+
+    private val floatTolerance = 0.001F
+
+    fun transform_whenIdentity_resultMatchesOriginal() {
+        val rect = MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+
+        rect.transform(Matrix())
+
+        assertThat(rect)
+            .isEqualTo(
+                MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+            )
+    }
+
+    @Test
+    fun transform_whenScale_scalesBounds() {
+        val rect = MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+
+        rect.transform(Matrix().apply { setScale(5F, -6F) })
+
+        assertThat(rect)
+            .isEqualTo(
+                MutableBox().populateFromTwoPoints(ImmutableVec(5F, -12F), ImmutableVec(15F, -24F))
+            )
+    }
+
+    @Test
+    fun transform_whenOffset_offsetsBounds() {
+        val rect = MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+
+        rect.transform(Matrix().apply { setTranslate(5F, -6F) })
+
+        assertThat(rect)
+            .isEqualTo(
+                MutableBox().populateFromTwoPoints(ImmutableVec(6F, -4F), ImmutableVec(8F, -2F))
+            )
+    }
+
+    @Test
+    fun transform_whenRotation_newBoundsIncludeRotatedRect() {
+        val rect = MutableBox().populateFromTwoPoints(ImmutableVec(0F, 0F), ImmutableVec(4F, 3F))
+
+        rect.transform(Matrix().apply { setRotate(-36.87F) })
+
+        assertThat(rect.xMin).isWithin(floatTolerance).of(0F)
+        assertThat(rect.yMin).isWithin(floatTolerance).of(-2.4F)
+        assertThat(rect.xMax).isWithin(floatTolerance).of(5F)
+        assertThat(rect.yMax).isWithin(floatTolerance).of(2.4F)
+    }
+
+    @Test
+    fun transform_whenDestinationSupplied_originalIsUnchanged() {
+        val rect = MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+
+        val dest = MutableBox().populateFromTwoPoints(ImmutableVec(0F, 0F), ImmutableVec(0F, 0F))
+
+        rect.transform(Matrix().apply { setScale(5F, -6F) }, dest)
+
+        assertThat(rect)
+            .isEqualTo(
+                MutableBox().populateFromTwoPoints(ImmutableVec(1F, 2F), ImmutableVec(3F, 4F))
+            )
+        assertThat(dest)
+            .isEqualTo(
+                MutableBox().populateFromTwoPoints(ImmutableVec(5F, -12F), ImmutableVec(15F, -24F))
+            )
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/StrokeInputPoolTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/StrokeInputPoolTest.kt
new file mode 100644
index 0000000..2f6236d
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/internal/StrokeInputPoolTest.kt
@@ -0,0 +1,511 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import android.view.MotionEvent
+import androidx.ink.authoring.testing.MultiTouchInputBuilder
+import androidx.ink.brush.InputToolType
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.StrokeInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class StrokeInputPoolTest {
+
+    @Test
+    fun obtain_withDefaultArgs_shouldGetStrokeInputThatMatchesParameters() {
+        val pool = StrokeInputPool()
+
+        val input = pool.obtain(x = 1F, y = 2F, elapsedTimeMillis = 3L)
+
+        assertThat(input.x).isEqualTo(1F)
+        assertThat(input.y).isEqualTo(2F)
+        assertThat(input.elapsedTimeMillis).isEqualTo(3L)
+        assertThat(input.toolType).isEqualTo(InputToolType.UNKNOWN)
+        assertThat(input.pressure).isEqualTo(StrokeInput.NO_PRESSURE)
+        assertThat(input.tiltRadians).isEqualTo(StrokeInput.NO_TILT)
+        assertThat(input.orientationRadians).isEqualTo(StrokeInput.NO_ORIENTATION)
+    }
+
+    @Test
+    fun obtain_withCustomArgs_shouldGetStrokeInputThatMatchesParameters() {
+        val pool = StrokeInputPool()
+
+        val input =
+            pool.obtain(
+                x = 1F,
+                y = 2F,
+                elapsedTimeMillis = 3L,
+                toolType = InputToolType.STYLUS,
+                pressure = 4F,
+                orientationRadians = 5F,
+                tiltRadians = 6F,
+            )
+
+        assertThat(input.x).isEqualTo(1F)
+        assertThat(input.y).isEqualTo(2F)
+        assertThat(input.elapsedTimeMillis).isEqualTo(3L)
+        assertThat(input.toolType).isEqualTo(InputToolType.STYLUS)
+        assertThat(input.pressure).isEqualTo(4F)
+        assertThat(input.orientationRadians).isEqualTo(5F)
+        assertThat(input.tiltRadians).isEqualTo(6F)
+    }
+
+    @Test
+    fun obtain_whenCalledTwiceWithoutRecycle_shouldReturnDifferentInstances() {
+        val pool = StrokeInputPool()
+
+        val input1 = pool.obtain(x = 1F, y = 2F, elapsedTimeMillis = 3L)
+        val input2 = pool.obtain(x = 4F, y = 5F, elapsedTimeMillis = 6L)
+
+        assertThat(input2).isNotSameInstanceAs(input1)
+        assertThat(input1.x).isEqualTo(1F)
+        assertThat(input1.y).isEqualTo(2F)
+        assertThat(input1.elapsedTimeMillis).isEqualTo(3L)
+        assertThat(input2.x).isEqualTo(4F)
+        assertThat(input2.y).isEqualTo(5F)
+        assertThat(input2.elapsedTimeMillis).isEqualTo(6L)
+    }
+
+    @Test
+    fun obtain_afterPreviousRecycle_shouldReceiveSameInstance() {
+        // Get a pool with zero pre-allocated instances to be able to verify with instance equality.
+        val pool = StrokeInputPool(0)
+        val input = pool.obtain(x = 1F, y = 2F, elapsedTimeMillis = 3L)
+        pool.recycle(input)
+
+        val anotherInput = pool.obtain(x = 4F, y = 5F, elapsedTimeMillis = 6L)
+
+        assertThat(anotherInput).isSameInstanceAs(input)
+        assertThat(input.x).isEqualTo(4F)
+        assertThat(input.y).isEqualTo(5F)
+        assertThat(input.elapsedTimeMillis).isEqualTo(6L)
+    }
+
+    @Test
+    fun obtainSingleValueForMotionEvent() {
+        val pool = StrokeInputPool()
+        val motionEventToStrokeCoordinatesTransform =
+            Matrix().apply {
+                setScale(3F, 3F)
+                postTranslate(10F, 50F)
+            }
+        val gestureStartTime = 3000L
+
+        val pointerIdsToStrokeInputs = mutableMapOf<Int, MutableList<StrokeInput>>()
+        MultiTouchInputBuilder(
+                pointerCount = 2,
+                toolTypes = intArrayOf(MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_FINGER),
+                startOrientation = arrayOf(-Math.PI.toFloat() / 2, null),
+                // Tilt is not supported in Robolectric today, so omit it even for the stylus
+                // pointer. It
+                // will return as a value of 0.0F.
+                startTilt = arrayOf(null, null),
+                historyIncrements = 3,
+                downtime = gestureStartTime,
+            )
+            .runGestureWith {
+                for (pointerIndex in 0 until it.pointerCount) {
+                    val pointerId = it.getPointerId(pointerIndex)
+                    val pointerInputList =
+                        pointerIdsToStrokeInputs.calculateIfAbsent(pointerId) { mutableListOf() }
+                    pointerInputList.add(
+                        pool.obtainSingleValueForMotionEvent(
+                            it,
+                            pointerIndex,
+                            motionEventToStrokeCoordinatesTransform,
+                            gestureStartTime,
+                        )
+                    )
+                }
+            }
+
+        assertThat(pointerIdsToStrokeInputs).hasSize(2)
+        assertThat(pointerIdsToStrokeInputs.keys).isEqualTo(setOf(9000, 9001))
+        assertThat(pointerIdsToStrokeInputs[9000]!!)
+            .comparingElementsUsing(strokeInputNearEqual())
+            .containsExactly(
+                StrokeInput.create(
+                    x = 10F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.05F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0F,
+                ),
+                StrokeInput.create(
+                    x = 10F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.05F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0F,
+                ),
+                StrokeInput.create(
+                    x = 310F,
+                    y = 350F,
+                    elapsedTimeMillis = 30,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.15F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.2F,
+                ),
+                StrokeInput.create(
+                    x = 610F,
+                    y = 650F,
+                    elapsedTimeMillis = 60,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.25F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.4F,
+                ),
+                StrokeInput.create(
+                    x = 910F,
+                    y = 950F,
+                    elapsedTimeMillis = 90,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.35F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.6F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 120,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 150,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 150,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+            )
+
+        assertThat(pointerIdsToStrokeInputs[9001]!!)
+            .comparingElementsUsing(strokeInputNearEqual())
+            .containsExactly(
+                StrokeInput.create(
+                    x = 310F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 610F,
+                    y = 350F,
+                    elapsedTimeMillis = 30,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 910F,
+                    y = 650F,
+                    elapsedTimeMillis = 60,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 950F,
+                    elapsedTimeMillis = 90,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1510F,
+                    y = 1250F,
+                    elapsedTimeMillis = 120,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1510F,
+                    y = 1250F,
+                    elapsedTimeMillis = 150,
+                    toolType = InputToolType.TOUCH,
+                ),
+            )
+    }
+
+    @Test
+    fun obtainAllHistoryForMotionEvent() {
+        val pool = StrokeInputPool()
+        val motionEventToStrokeCoordinatesTransform =
+            Matrix().apply {
+                setScale(3F, 3F)
+                postTranslate(10F, 50F)
+            }
+        val gestureStartTime = 3000L
+
+        val pointerIdsToStrokeInputs = mutableMapOf<Int, MutableList<StrokeInput>>()
+        MultiTouchInputBuilder(
+                pointerCount = 2,
+                toolTypes = intArrayOf(MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_FINGER),
+                // Tilt is not supported in Robolectric today, so omit it even for the stylus
+                // pointer. It
+                // will return as a value of 0.0F.
+                startTilt = arrayOf(null, null),
+                startOrientation = arrayOf(-Math.PI.toFloat() / 2, null),
+                historyIncrements = 2,
+                downtime = gestureStartTime,
+            )
+            .runGestureWith {
+                for (pointerIndex in 0 until it.pointerCount) {
+                    val outBatchBuilder = MutableStrokeInputBatch()
+                    pool.obtainAllHistoryForMotionEvent(
+                        it,
+                        pointerIndex,
+                        motionEventToStrokeCoordinatesTransform,
+                        gestureStartTime,
+                        outBatch = outBatchBuilder,
+                    )
+                    val outBatch = outBatchBuilder.asImmutable()
+                    val pointerId = it.getPointerId(pointerIndex)
+                    val pointerInputList =
+                        pointerIdsToStrokeInputs.calculateIfAbsent(pointerId) { mutableListOf() }
+                    for (i in 0 until outBatch.size) {
+                        pointerInputList.add(outBatch.get(i))
+                    }
+                }
+            }
+
+        assertThat(pointerIdsToStrokeInputs).hasSize(2)
+        assertThat(pointerIdsToStrokeInputs.keys).isEqualTo(setOf(9000, 9001))
+        assertThat(pointerIdsToStrokeInputs[9000]!!)
+            .comparingElementsUsing(strokeInputNearEqual())
+            .containsExactly(
+                StrokeInput.create(
+                    x = 10F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.05F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0F,
+                ),
+                StrokeInput.create(
+                    x = 10F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.05F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0F,
+                ),
+                StrokeInput.create(
+                    x = 160F,
+                    y = 200F,
+                    elapsedTimeMillis = 10,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.1F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.1F,
+                ),
+                StrokeInput.create(
+                    x = 310F,
+                    y = 350F,
+                    elapsedTimeMillis = 20,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.15F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.2F,
+                ),
+                StrokeInput.create(
+                    x = 460F,
+                    y = 500F,
+                    elapsedTimeMillis = 30,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.2F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.3F,
+                ),
+                StrokeInput.create(
+                    x = 610F,
+                    y = 650F,
+                    elapsedTimeMillis = 40,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.25F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.4F,
+                ),
+                StrokeInput.create(
+                    x = 760F,
+                    y = 800F,
+                    elapsedTimeMillis = 50,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.3F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.5F,
+                ),
+                StrokeInput.create(
+                    x = 910F,
+                    y = 950F,
+                    elapsedTimeMillis = 60,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.35F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.6F,
+                ),
+                StrokeInput.create(
+                    x = 1060F,
+                    y = 1100F,
+                    elapsedTimeMillis = 70,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.4F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.7F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 80,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 100,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 1250F,
+                    elapsedTimeMillis = 100,
+                    toolType = InputToolType.STYLUS,
+                    pressure = 0.45F,
+                    tiltRadians = 0F,
+                    orientationRadians = 0.8F,
+                ),
+            )
+
+        assertThat(pointerIdsToStrokeInputs[9001]!!)
+            .comparingElementsUsing(strokeInputNearEqual())
+            .containsExactly(
+                StrokeInput.create(
+                    x = 310F,
+                    y = 50F,
+                    elapsedTimeMillis = 0,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 460F,
+                    y = 200F,
+                    elapsedTimeMillis = 10,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 610F,
+                    y = 350F,
+                    elapsedTimeMillis = 20,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 760F,
+                    y = 500F,
+                    elapsedTimeMillis = 30,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 910F,
+                    y = 650F,
+                    elapsedTimeMillis = 40,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1060F,
+                    y = 800F,
+                    elapsedTimeMillis = 50,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1210F,
+                    y = 950F,
+                    elapsedTimeMillis = 60,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1360F,
+                    y = 1100F,
+                    elapsedTimeMillis = 70,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1510F,
+                    y = 1250F,
+                    elapsedTimeMillis = 80,
+                    toolType = InputToolType.TOUCH,
+                ),
+                StrokeInput.create(
+                    x = 1510F,
+                    y = 1250F,
+                    elapsedTimeMillis = 100,
+                    toolType = InputToolType.TOUCH,
+                ),
+            )
+    }
+}
+
+/** A [Correspondence] for fuzzy matching of a [StrokeInput]. */
+private fun strokeInputNearEqual(
+    tolerance: Double = 0.001
+): Correspondence<StrokeInput, StrokeInput> =
+    Correspondence.from(
+        { actual: StrokeInput?, expected: StrokeInput? ->
+            if (expected == null || actual == null) return@from actual == expected
+            val floatTolerance = Correspondence.tolerance(tolerance)
+            floatTolerance.compare(actual.x, expected.x) &&
+                floatTolerance.compare(actual.y, expected.y) &&
+                actual.elapsedTimeMillis == expected.elapsedTimeMillis &&
+                actual.toolType == expected.toolType &&
+                floatTolerance.compare(actual.pressure, expected.pressure) &&
+                floatTolerance.compare(actual.tiltRadians, expected.tiltRadians) &&
+                floatTolerance.compare(actual.orientationRadians, expected.orientationRadians)
+        },
+        "is approximately equal to",
+    )
+
+/** Like [MutableMap.computeIfAbsent], but available on all API levels. */
+private fun <K, V> MutableMap<K, V>.calculateIfAbsent(key: K, mappingFunction: (K) -> V): V {
+    return get(key) ?: mappingFunction(key).also { put(key, it) }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataPoolTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataPoolTest.kt
new file mode 100644
index 0000000..b3fa708
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataPoolTest.kt
@@ -0,0 +1,440 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import android.os.Build
+import android.view.MotionEvent
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalLatencyDataApi::class)
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class LatencyDataPoolTest {
+    @Test
+    fun obtain_getsDifferentInstancesEachTime() {
+        val pool = LatencyDataPool(2)
+
+        val data1 = pool.obtain()
+        val data2 = pool.obtain()
+
+        assertThat(data1).isNotSameInstanceAs(data2)
+    }
+
+    @Test
+    fun obtain_canGetMoreInstancesThanWerePreallocated() {
+        val pool = LatencyDataPool(2)
+
+        val data1 = pool.obtain()
+        val data2 = pool.obtain()
+        val data3 = pool.obtain()
+
+        assertThat(data3).isNotSameInstanceAs(data1)
+        assertThat(data3).isNotSameInstanceAs(data2)
+    }
+
+    @Test
+    fun recycle_resetsContents() {
+        val pool = LatencyDataPool(2)
+
+        val data1 = pool.obtain().apply { osDetectsEvent = 123L }
+        val data2 = pool.obtain().apply { osDetectsEvent = 456L }
+        val data3 = pool.obtain().apply { osDetectsEvent = 789L }
+
+        pool.recycle(data1)
+        pool.recycle(data2)
+        pool.recycle(data3)
+
+        assertThat(data1.osDetectsEvent).isEqualTo(Long.MIN_VALUE)
+        assertThat(data2.osDetectsEvent).isEqualTo(Long.MIN_VALUE)
+        assertThat(data3.osDetectsEvent).isEqualTo(Long.MIN_VALUE)
+    }
+
+    @Test
+    fun recycle_reusesInstancesWhenObtainedAgain() {
+        val pool = LatencyDataPool(2)
+
+        val data1 = pool.obtain()
+        val data2 = pool.obtain()
+        val data3 = pool.obtain()
+
+        pool.recycle(data1)
+        pool.recycle(data2)
+        pool.recycle(data3)
+
+        val data4 = pool.obtain()
+        val data5 = pool.obtain()
+        val data6 = pool.obtain()
+        val data7 = pool.obtain() // New instance; wasn't already in the pool.
+
+        assertThat(data4).isSameInstanceAs(data1)
+        assertThat(data5).isSameInstanceAs(data2)
+        assertThat(data6).isSameInstanceAs(data3)
+        assertThat(data7).isNotSameInstanceAs(data1)
+        assertThat(data7).isNotSameInstanceAs(data2)
+        assertThat(data7).isNotSameInstanceAs(data3)
+    }
+
+    // The code path for recording `osDetectsEvent` differs depending on SDK level. This test
+    // provides
+    // coverage for the Pre-U path.
+    @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.TIRAMISU)
+    fun obtainLatencyDataForSingleEvent_SdkPreU_setsInitialValues() {
+        val pool = LatencyDataPool(2)
+
+        // eventTime is 456 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_UP, 10f, 20f, 0)
+        val strokeId = InProgressStrokeId()
+        val data =
+            pool.obtainLatencyDataForSingleEvent(
+                event,
+                LatencyData.StrokeAction.FINISH,
+                strokeId,
+                strokesViewGetsActionTimeNanos = 457_000_000,
+            )
+
+        // Before SDK U, the internal representation of the event time is in millis, but LatencyData
+        // always uses nanos.
+        assertThat(data.eventAction).isEqualTo(LatencyData.EventAction.UP)
+        assertThat(data.strokeAction).isEqualTo(LatencyData.StrokeAction.FINISH)
+        assertThat(data.strokeId).isEqualTo(strokeId)
+        assertThat(data.batchSize).isEqualTo(1)
+        assertThat(data.batchIndex).isEqualTo(0)
+        assertThat(data.osDetectsEvent).isEqualTo(456_000_000L) // nanoseconds
+        assertThat(data.strokesViewGetsAction).isEqualTo(457_000_000L)
+    }
+
+    // The code path for recording `osDetectsEvent` differs depending on SDK level. This test
+    // provides
+    // coverage for the U+ path.
+    @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun obtainLatencyDataForSingleEvent_SdkUPlus_setsInitialValues() {
+        val pool = LatencyDataPool(2)
+
+        // eventTime is 456 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_UP, 10f, 20f, 0)
+        val strokeId = InProgressStrokeId()
+        val data =
+            pool.obtainLatencyDataForSingleEvent(
+                event,
+                LatencyData.StrokeAction.FINISH,
+                strokeId,
+                strokesViewGetsActionTimeNanos = 457_000_000,
+            )
+
+        // In SDK U+, the internal representation of the event time is in nanoseconds, and full
+        // precision is exposed in LatencyData. However, `MotionEvent.obtain` only accepts
+        // milliseconds,
+        // so there's no way to observe this full precision with fake input data.
+        assertThat(data.eventAction).isEqualTo(LatencyData.EventAction.UP)
+        assertThat(data.strokeAction).isEqualTo(LatencyData.StrokeAction.FINISH)
+        assertThat(data.strokeId).isEqualTo(strokeId)
+        assertThat(data.batchSize).isEqualTo(1)
+        assertThat(data.batchIndex).isEqualTo(0)
+        assertThat(data.osDetectsEvent).isEqualTo(456_000_000L) // nanoseconds
+        assertThat(data.strokesViewGetsAction).isEqualTo(457_000_000L)
+    }
+
+    @Test
+    fun obtainLatencyDataForSingleEvent_setsPredictedMove() {
+        val pool = LatencyDataPool(2)
+        val strokeId = InProgressStrokeId()
+
+        // eventTime is 456 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+
+        val regularData =
+            pool.obtainLatencyDataForSingleEvent(
+                event,
+                LatencyData.StrokeAction.ADD,
+                strokeId,
+                strokesViewGetsActionTimeNanos = 457_000_000,
+            )
+        assertThat(regularData.eventAction).isEqualTo(LatencyData.EventAction.MOVE)
+        assertThat(regularData.strokeAction).isEqualTo(LatencyData.StrokeAction.ADD)
+        assertThat(regularData.strokeId).isEqualTo(strokeId)
+        assertThat(regularData.strokeId.hashCode()).isEqualTo(strokeId.hashCode())
+        assertThat(regularData.batchSize).isEqualTo(1)
+        assertThat(regularData.batchIndex).isEqualTo(0)
+        assertThat(regularData.osDetectsEvent).isEqualTo(456_000_000L) // nanoseconds
+        assertThat(regularData.strokesViewGetsAction).isEqualTo(457_000_000L)
+
+        val predictedData =
+            pool.obtainLatencyDataForSingleEvent(
+                event,
+                LatencyData.StrokeAction.PREDICTED_ADD,
+                strokeId,
+                predicted = true,
+                strokesViewGetsActionTimeNanos = 457_000_000,
+            )
+        assertThat(predictedData.eventAction).isEqualTo(LatencyData.EventAction.PREDICTED_MOVE)
+        assertThat(predictedData.strokeAction).isEqualTo(LatencyData.StrokeAction.PREDICTED_ADD)
+        assertThat(predictedData.strokeId).isEqualTo(strokeId)
+        assertThat(predictedData.strokeId.hashCode()).isEqualTo(strokeId.hashCode())
+        assertThat(predictedData.batchSize).isEqualTo(1)
+        assertThat(predictedData.batchIndex).isEqualTo(0)
+        assertThat(predictedData.osDetectsEvent).isEqualTo(456_000_000L) // nanoseconds
+        assertThat(predictedData.strokesViewGetsAction).isEqualTo(457_000_000L)
+    }
+
+    @Test
+    fun obtainLatencyDataForSingleEvent_ignoresHistoricalEvents() {
+        val pool = LatencyDataPool(2)
+
+        // eventTimes are 456, 789, and 1011 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        event.addBatch(789, 11f, 21f, 1f, 1f, 0)
+        event.addBatch(1011, 12f, 22f, 1f, 1f, 0)
+        val strokeId = InProgressStrokeId()
+        val data =
+            pool.obtainLatencyDataForSingleEvent(
+                event,
+                LatencyData.StrokeAction.ADD,
+                strokeId,
+                strokesViewGetsActionTimeNanos = 1012_000_000,
+            )
+
+        assertThat(data.eventAction).isEqualTo(LatencyData.EventAction.MOVE)
+        assertThat(data.strokeAction).isEqualTo(LatencyData.StrokeAction.ADD)
+        assertThat(data.strokeId).isEqualTo(strokeId)
+        assertThat(data.strokeId.hashCode()).isEqualTo(strokeId.hashCode())
+        assertThat(data.batchSize).isEqualTo(3)
+        assertThat(data.batchIndex).isEqualTo(2)
+        assertThat(data.osDetectsEvent).isEqualTo(1011_000_000L) // nanoseconds
+        assertThat(data.strokesViewGetsAction).isEqualTo(1012_000_000L)
+    }
+
+    // The code path for recording `osDetectsEvent` differs depending on SDK level. This test
+    // provides
+    // coverage for the Pre-U path.
+    @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.TIRAMISU)
+    fun obtainLatencyDataForPrimaryAndHistoricalEvents_SdkPreU_makesDataForEveryEventInBatch() {
+        val pool = LatencyDataPool(2)
+
+        // eventTimes are 456, 789, and 1011 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        event.addBatch(789, 11f, 21f, 1f, 1f, 0)
+        event.addBatch(1011, 12f, 22f, 1f, 1f, 0)
+        val strokeId = InProgressStrokeId()
+
+        val datas = ArrayDeque<LatencyData>()
+        pool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+            event,
+            LatencyData.StrokeAction.ADD,
+            strokeId,
+            strokesViewGetsActionTimeNanos = 1012_000_000,
+            predicted = false,
+            datas,
+        )
+
+        val expectedDatas =
+            mutableListOf<LatencyData>(
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 0
+                    osDetectsEvent = 456_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 1
+                    osDetectsEvent = 789_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 2
+                    osDetectsEvent = 1011_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+            )
+        assertThat(datas)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(expectedDatas)
+            .inOrder()
+    }
+
+    // The code path for recording `osDetectsEvent` differs depending on SDK level. This test
+    // provides
+    // coverage for the U+ path.
+    @Test
+    @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    fun obtainLatencyDataForPrimaryAndHistoricalEvents_SdkUPlus_makesDataForEveryEventInBatch() {
+        val pool = LatencyDataPool(2)
+
+        // eventTimes are 456, 789, and 1011 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        event.addBatch(789, 11f, 21f, 1f, 1f, 0)
+        event.addBatch(1011, 12f, 22f, 1f, 1f, 0)
+        val strokeId = InProgressStrokeId()
+
+        val datas = ArrayDeque<LatencyData>()
+        pool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+            event,
+            LatencyData.StrokeAction.ADD,
+            strokeId,
+            strokesViewGetsActionTimeNanos = 1012_000_000,
+            predicted = false,
+            datas,
+        )
+
+        val expectedDatas =
+            mutableListOf<LatencyData>(
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 0
+                    osDetectsEvent = 456_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 1
+                    osDetectsEvent = 789_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 2
+                    osDetectsEvent = 1011_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+            )
+        assertThat(datas)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(expectedDatas)
+            .inOrder()
+    }
+
+    @Test
+    fun obtainLatencyDataForPrimaryAndHistoricalEvents_makesJustOneForEventWithNoHistory() {
+        val pool = LatencyDataPool(2)
+
+        // eventTime is 456 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        val strokeId = InProgressStrokeId()
+
+        val datas = ArrayDeque<LatencyData>()
+        pool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+            event,
+            LatencyData.StrokeAction.ADD,
+            strokeId,
+            strokesViewGetsActionTimeNanos = 457_000_000,
+            predicted = false,
+            datas,
+        )
+
+        val expectedDatas =
+            mutableListOf<LatencyData>(
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.MOVE
+                    strokeAction = LatencyData.StrokeAction.ADD
+                    this.strokeId = strokeId
+                    batchSize = 1
+                    batchIndex = 0
+                    osDetectsEvent = 456_000_000L // nanoseconds
+                    strokesViewGetsAction = 457_000_000L
+                }
+            )
+        assertThat(datas)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(expectedDatas)
+            .inOrder()
+    }
+
+    @Test
+    fun obtainLatencyDataForPrimaryAndHistoricalEvents_setsPredictedMove() {
+        val pool = LatencyDataPool(2)
+
+        // eventTimes are 456, 789, and 1011 milliseconds.
+        val event = MotionEvent.obtain(123, 456, MotionEvent.ACTION_MOVE, 10f, 20f, 0)
+        event.addBatch(789, 11f, 21f, 1f, 1f, 0)
+        event.addBatch(1011, 12f, 22f, 1f, 1f, 0)
+        val strokeId = InProgressStrokeId()
+
+        val datas = ArrayDeque<LatencyData>()
+        pool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+            event,
+            LatencyData.StrokeAction.PREDICTED_ADD,
+            strokeId,
+            strokesViewGetsActionTimeNanos = 1012_000_000,
+            predicted = true,
+            datas,
+        )
+
+        val expectedDatas =
+            mutableListOf<LatencyData>(
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                    strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 0
+                    osDetectsEvent = 456_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                    strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 1
+                    osDetectsEvent = 789_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+                LatencyData().apply {
+                    eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                    strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                    this.strokeId = strokeId
+                    batchSize = 3
+                    batchIndex = 2
+                    osDetectsEvent = 1011_000_000L // nanoseconds
+                    strokesViewGetsAction = 1012_000_000L
+                },
+            )
+        assertThat(datas)
+            .comparingElementsUsing(latencyDataEqual)
+            .containsExactlyElementsIn(expectedDatas)
+            .inOrder()
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTest.kt
new file mode 100644
index 0000000..ead405a
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTest.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import android.view.MotionEvent
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalLatencyDataApi::class)
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class LatencyDataTest {
+    @Test
+    fun setters_setFieldsDirectly() {
+        val strokeId = InProgressStrokeId()
+        val latencyData =
+            LatencyData().apply {
+                eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                this.strokeId = strokeId
+                batchSize = 5
+                batchIndex = 3
+                osDetectsEvent = -123456L
+                strokesViewGetsAction = 0L
+                strokesViewFinishesDrawCalls = 123L
+                estimatedPixelPresentationTime = 456L
+                canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 789L
+                hwuiInProgressStrokesRenderHelperData.finishesDrawCalls = 1011L
+            }
+
+        with(latencyData) {
+            assertThat(eventAction).isEqualTo(LatencyData.EventAction.PREDICTED_MOVE)
+            assertThat(strokeAction).isEqualTo(LatencyData.StrokeAction.PREDICTED_ADD)
+            assertThat(this.strokeId).isEqualTo(strokeId)
+            assertThat(this.strokeId.hashCode()).isEqualTo(strokeId.hashCode())
+            assertThat(batchSize).isEqualTo(5)
+            assertThat(batchIndex).isEqualTo(3)
+            assertThat(osDetectsEvent).isEqualTo(-123456L)
+            assertThat(strokesViewGetsAction).isEqualTo(0L)
+            assertThat(strokesViewFinishesDrawCalls).isEqualTo(123L)
+            assertThat(estimatedPixelPresentationTime).isEqualTo(456L)
+            assertThat(canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls).isEqualTo(789L)
+            assertThat(hwuiInProgressStrokesRenderHelperData.finishesDrawCalls).isEqualTo(1011L)
+        }
+    }
+
+    @Test
+    fun reset_resetsAllFields() {
+        val strokeId = InProgressStrokeId()
+        val latencyData =
+            LatencyData().apply {
+                eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                this.strokeId = strokeId
+                batchSize = 5
+                batchIndex = 3
+                osDetectsEvent = -123456L
+                strokesViewGetsAction = 0L
+                strokesViewFinishesDrawCalls = 123L
+                estimatedPixelPresentationTime = 456L
+                canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 789L
+                hwuiInProgressStrokesRenderHelperData.finishesDrawCalls = 1011L
+            }
+        latencyData.reset()
+
+        with(latencyData) {
+            assertThat(eventAction).isEqualTo(LatencyData.EventAction.UNKNOWN)
+            assertThat(strokeAction).isEqualTo(LatencyData.StrokeAction.UNKNOWN)
+            assertThat(this.strokeId).isEqualTo(LatencyData.UNKNOWN_STROKE_ID)
+            assertThat(this.strokeId.hashCode()).isEqualTo(LatencyData.UNKNOWN_STROKE_ID.hashCode())
+            assertThat(batchSize).isEqualTo(Int.MIN_VALUE)
+            assertThat(batchIndex).isEqualTo(Int.MIN_VALUE)
+            assertThat(osDetectsEvent).isEqualTo(Long.MIN_VALUE)
+            assertThat(strokesViewGetsAction).isEqualTo(Long.MIN_VALUE)
+            assertThat(strokesViewFinishesDrawCalls).isEqualTo(Long.MIN_VALUE)
+            assertThat(estimatedPixelPresentationTime).isEqualTo(Long.MIN_VALUE)
+            assertThat(canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls)
+                .isEqualTo(Long.MIN_VALUE)
+            assertThat(hwuiInProgressStrokesRenderHelperData.finishesDrawCalls)
+                .isEqualTo(Long.MIN_VALUE)
+        }
+    }
+
+    @Test
+    fun toString_showsAllFields() {
+        val latencyData =
+            LatencyData().apply {
+                eventAction = LatencyData.EventAction.PREDICTED_MOVE
+                strokeAction = LatencyData.StrokeAction.PREDICTED_ADD
+                strokeId = InProgressStrokeId()
+                batchSize = 555
+                batchIndex = 333
+                osDetectsEvent = -123456L
+                strokesViewGetsAction = 0L
+                strokesViewFinishesDrawCalls = 123L
+                estimatedPixelPresentationTime = 456L
+                canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = 789L
+                hwuiInProgressStrokesRenderHelperData.finishesDrawCalls = 1011L
+            }
+
+        val str = latencyData.toString()
+        assertThat(str).contains("PREDICTED_ADD")
+        assertThat(str).contains("PREDICTED_MOVE")
+        assertThat(str).contains("InProgressStrokeId")
+        assertThat(str).contains("555")
+        assertThat(str).contains("333")
+        assertThat(str).contains("-123456")
+        assertThat(str).contains("0")
+        assertThat(str).contains("123")
+        assertThat(str).contains("456")
+        assertThat(str).contains("789")
+        assertThat(str).contains("1011")
+    }
+
+    @Test
+    fun eventActionFromMotionEvent_mapsToEventAction() {
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.DOWN)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_POINTER_DOWN, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.DOWN)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.MOVE)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.UP)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_POINTER_UP, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.UP)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.CANCEL)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_SCROLL, 0f, 0f, 0)
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.UNKNOWN)
+    }
+
+    fun eventActionFromMotionEvent_predictedOnlyAppliesToMove() {
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0f, 0f, 0),
+                    predicted = true,
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.PREDICTED_MOVE)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),
+                    predicted = true,
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.DOWN)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0),
+                    predicted = true,
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.UP)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0),
+                    predicted = true,
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.CANCEL)
+        assertThat(
+                LatencyData.EventAction.fromMotionEvent(
+                    MotionEvent.obtain(0, 0, MotionEvent.ACTION_SCROLL, 0f, 0f, 0),
+                    predicted = true,
+                )
+            )
+            .isEqualTo(LatencyData.EventAction.UNKNOWN)
+    }
+
+    @Test
+    fun eventActionToString_isAccurate() {
+        assertThat(LatencyData.EventAction.UNKNOWN.toString()).endsWith("UNKNOWN")
+        assertThat(LatencyData.EventAction.DOWN.toString()).endsWith("DOWN")
+        assertThat(LatencyData.EventAction.MOVE.toString()).endsWith("MOVE")
+        assertThat(LatencyData.EventAction.PREDICTED_MOVE.toString()).endsWith("PREDICTED_MOVE")
+        assertThat(LatencyData.EventAction.UP.toString()).endsWith("UP")
+        assertThat(LatencyData.EventAction.CANCEL.toString()).endsWith("CANCEL")
+    }
+
+    @Test
+    fun eventActionEquals_isAccurate() {
+        assertThat(LatencyData.EventAction.DOWN).isEqualTo(LatencyData.EventAction.DOWN)
+        assertThat(LatencyData.EventAction.MOVE)
+            .isNotEqualTo(LatencyData.EventAction.PREDICTED_MOVE)
+        assertThat(LatencyData.EventAction.UNKNOWN).isNotEqualTo(0)
+    }
+
+    @Test
+    fun strokeActionToString_isAccurate() {
+        assertThat(LatencyData.StrokeAction.UNKNOWN.toString()).endsWith("UNKNOWN")
+        assertThat(LatencyData.StrokeAction.START.toString()).endsWith("START")
+        assertThat(LatencyData.StrokeAction.ADD.toString()).endsWith("ADD")
+        assertThat(LatencyData.StrokeAction.PREDICTED_ADD.toString()).endsWith("PREDICTED_ADD")
+        assertThat(LatencyData.StrokeAction.FINISH.toString()).endsWith("FINISH")
+        assertThat(LatencyData.StrokeAction.CANCEL.toString()).endsWith("CANCEL")
+    }
+
+    @Test
+    fun strokeActionEquals_isAccurate() {
+        assertThat(LatencyData.StrokeAction.START).isEqualTo(LatencyData.StrokeAction.START)
+        assertThat(LatencyData.StrokeAction.ADD)
+            .isNotEqualTo(LatencyData.StrokeAction.PREDICTED_ADD)
+        assertThat(LatencyData.StrokeAction.UNKNOWN).isNotEqualTo(0)
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTestUtil.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTestUtil.kt
new file mode 100644
index 0000000..a3d62ec
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/latency/LatencyDataTestUtil.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import com.google.common.truth.Correspondence
+
+@ExperimentalLatencyDataApi
+public val latencyDataEqual: Correspondence<LatencyData, LatencyData> =
+    Correspondence.from(
+        { actual: LatencyData?, expected: LatencyData? ->
+            if (expected == null || actual == null) return@from actual == expected
+            actual.eventAction == expected.eventAction &&
+                actual.strokeAction == expected.strokeAction &&
+                actual.strokeId == expected.strokeId &&
+                actual.batchSize == expected.batchSize &&
+                actual.batchIndex == expected.batchIndex &&
+                actual.osDetectsEvent == expected.osDetectsEvent &&
+                actual.strokesViewGetsAction == expected.strokesViewGetsAction &&
+                actual.strokesViewFinishesDrawCalls == expected.strokesViewFinishesDrawCalls &&
+                actual.estimatedPixelPresentationTime == expected.estimatedPixelPresentationTime &&
+                actual.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls ==
+                    expected.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls &&
+                actual.hwuiInProgressStrokesRenderHelperData.finishesDrawCalls ==
+                    expected.hwuiInProgressStrokesRenderHelperData.finishesDrawCalls
+        },
+        "equals",
+    )
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/InputStreamBuilderTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/InputStreamBuilderTest.kt
new file mode 100644
index 0000000..8124951
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/InputStreamBuilderTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.testing
+
+import android.view.InputDevice
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class InputStreamBuilderTest {
+
+    @Test
+    fun fingerLine_eventsHaveToolTypeFinger() {
+        InputStreamBuilder.fingerLine(0F, 0F, 100F, 200F).runInputStreamWith { event ->
+            assertThat(event.pointerCount).isEqualTo(1)
+            assertThat(event.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_FINGER)
+        }
+    }
+
+    @Test
+    fun mouseLine_eventsHaveToolTypeMouse() {
+        InputStreamBuilder.mouseLine(MotionEvent.BUTTON_PRIMARY, 0F, 0F, 100F, 200F)
+            .runInputStreamWith { event ->
+                assertThat(event.pointerCount).isEqualTo(1)
+                assertThat(event.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_MOUSE)
+            }
+    }
+
+    @Test
+    fun mouseLine_eventsHaveButtonState() {
+        val buttons =
+            arrayOf(
+                MotionEvent.BUTTON_PRIMARY,
+                MotionEvent.BUTTON_SECONDARY,
+                MotionEvent.BUTTON_TERTIARY
+            )
+        for (button in buttons) {
+            val builder = InputStreamBuilder.mouseLine(button, 0F, 0F, 100F, 200F)
+            builder.runWithDownEvent { event -> assertThat(event.buttonState).isEqualTo(button) }
+            builder.runWithMoveEvent { event -> assertThat(event.buttonState).isEqualTo(button) }
+            // The button should no longer be held down on the up event.
+            builder.runWithUpEvent { event -> assertThat(event.buttonState).isEqualTo(0) }
+        }
+    }
+
+    @Test
+    fun stylusLine_eventsHaveToolTypeStylus() {
+        InputStreamBuilder.stylusLine(0F, 0F, 100F, 200F).runInputStreamWith { event ->
+            assertThat(event.pointerCount).isEqualTo(1)
+            assertThat(event.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_STYLUS)
+        }
+    }
+
+    @Test
+    fun stylusLine_eventsHaveCorrespondingActions() {
+        val builder = InputStreamBuilder.stylusLine(0F, 0F, 100F, 200F)
+        builder.runWithDownEvent { event ->
+            assertThat(event.actionMasked).isEqualTo(MotionEvent.ACTION_DOWN)
+        }
+        builder.runWithMoveEvent { event ->
+            assertThat(event.actionMasked).isEqualTo(MotionEvent.ACTION_MOVE)
+        }
+        builder.runWithUpEvent { event ->
+            assertThat(event.actionMasked).isEqualTo(MotionEvent.ACTION_UP)
+        }
+    }
+
+    @Test
+    fun stylusLine_pointerPositionsFollowSegment() {
+        val builder = InputStreamBuilder.stylusLine(0F, 0F, 100F, 200F)
+        builder.runWithDownEvent { event ->
+            assertThat(event.getX()).isEqualTo(0F)
+            assertThat(event.getY()).isEqualTo(0F)
+        }
+        builder.runWithMoveEvent { event ->
+            assertThat(event.getX()).isEqualTo(50F)
+            assertThat(event.getY()).isEqualTo(100F)
+        }
+        builder.runWithUpEvent { event ->
+            assertThat(event.getX()).isEqualTo(100F)
+            assertThat(event.getY()).isEqualTo(200F)
+        }
+    }
+
+    @Test
+    fun scrollWheel_hasMouseToolTypeAndSource() {
+        InputStreamBuilder.scrollWheel(
+            1F,
+            -1F,
+            { event ->
+                assertThat(event.pointerCount).isEqualTo(1)
+                assertThat(event.getToolType(0)).isEqualTo(MotionEvent.TOOL_TYPE_MOUSE)
+                assertThat(event.isFromSource(InputDevice.SOURCE_MOUSE)).isTrue()
+            },
+        )
+    }
+
+    @Test
+    fun scrollWheel_hasSpecifiedScrollAxes() {
+        InputStreamBuilder.scrollWheel(
+            0.75F,
+            -0.5F,
+            { event ->
+                assertThat(event.getAxisValue(MotionEvent.AXIS_HSCROLL)).isEqualTo(0.75F)
+                assertThat(event.getAxisValue(MotionEvent.AXIS_VSCROLL)).isEqualTo(-0.5F)
+            },
+        )
+    }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilderTest.kt b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilderTest.kt
new file mode 100644
index 0000000..dd65794
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilderTest.kt
@@ -0,0 +1,493 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.testing
+
+import android.graphics.PointF
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Correspondence
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class MultiTouchInputBuilderTest {
+
+    @Test
+    fun oneStylusWithHistory() {
+        val expectedActions =
+            listOf(
+                MotionEvent.ACTION_DOWN,
+                MotionEvent.ACTION_MOVE,
+                MotionEvent.ACTION_MOVE,
+                MotionEvent.ACTION_MOVE,
+                MotionEvent.ACTION_MOVE,
+                MotionEvent.ACTION_UP,
+            )
+        val expectedPointerCounts = listOf(1, 1, 1, 1, 1, 1)
+        val expectedToolTypes =
+            listOf(
+                MotionEvent.TOOL_TYPE_STYLUS,
+                MotionEvent.TOOL_TYPE_STYLUS,
+                MotionEvent.TOOL_TYPE_STYLUS,
+                MotionEvent.TOOL_TYPE_STYLUS,
+                MotionEvent.TOOL_TYPE_STYLUS,
+                MotionEvent.TOOL_TYPE_STYLUS,
+            )
+        val expectedTimes =
+            listOf(
+                listOf(1000L),
+                listOf(1010L, 1020L),
+                listOf(1030L, 1040L),
+                listOf(1050L, 1060L),
+                listOf(1070L, 1080L),
+                // 1090L is not present since the ACTION_UP event does not contain history values.
+                listOf(1100L),
+            )
+        val expectedPositions =
+            listOf(
+                listOf(PointF(0F, 0F)),
+                listOf(PointF(50F, 50F), PointF(100F, 100F)),
+                listOf(PointF(150F, 150F), PointF(200F, 200F)),
+                listOf(PointF(250F, 250F), PointF(300F, 300F)),
+                listOf(PointF(350F, 350F), PointF(400F, 400F)),
+                listOf(PointF(400F, 400F)),
+            )
+        val expectedPressures =
+            listOf(
+                listOf(0.05F),
+                listOf(0.1F, 0.15F),
+                listOf(0.2F, 0.25F),
+                listOf(0.3F, 0.35F),
+                listOf(0.4F, 0.45F),
+                listOf(0.45F),
+            )
+        val expectedOrientations =
+            listOf(
+                listOf(0.01F),
+                listOf(0.11F, 0.21F),
+                listOf(0.31F, 0.41F),
+                listOf(0.51F, 0.61F),
+                listOf(0.71F, 0.81F),
+                listOf(0.81F),
+            )
+        val expectedTilts =
+            listOf(
+                listOf(0.07F),
+                listOf(0.12F, 0.17F),
+                listOf(0.22F, 0.27F),
+                listOf(0.32F, 0.37F),
+                listOf(0.42F, 0.47F),
+                listOf(0.47F),
+            )
+
+        val actualActions = mutableListOf<Int>()
+        val actualPointerCounts = mutableListOf<Int>()
+        val actualToolTypes = mutableListOf<Int>()
+        val actualTimes = mutableListOf<MutableList<Long>>()
+        val actualPositions = mutableListOf<MutableList<PointF>>()
+        val actualPressures = mutableListOf<MutableList<Float>>()
+        val actualOrientations = mutableListOf<MutableList<Float>>()
+        val actualTilts = mutableListOf<MutableList<Float>>()
+
+        MultiTouchInputBuilder(
+                pointerCount = 1,
+                toolTypes = intArrayOf(MotionEvent.TOOL_TYPE_STYLUS),
+                historyIncrements = 2,
+            )
+            .runGestureWith {
+                actualActions.add(it.action)
+                actualPointerCounts.add(it.pointerCount)
+                actualToolTypes.add(it.getToolType(0))
+
+                val times = mutableListOf<Long>().also(actualTimes::add)
+                val positions = mutableListOf<PointF>().also(actualPositions::add)
+                val pressures = mutableListOf<Float>().also(actualPressures::add)
+                val orientations = mutableListOf<Float>().also(actualOrientations::add)
+                val tilts = mutableListOf<Float>().also(actualTilts::add)
+                for (h in 0 until it.historySize) {
+                    times.add(it.getHistoricalEventTime(h))
+                    positions.add(PointF(it.getHistoricalX(h), it.getHistoricalY(h)))
+                    pressures.add(it.getHistoricalPressure(h))
+                    orientations.add(it.getHistoricalOrientation(h))
+                    tilts.add(it.getHistoricalAxisValue(MotionEvent.AXIS_TILT, h))
+                }
+                times.add(it.eventTime)
+                positions.add(PointF(it.x, it.y))
+                pressures.add(it.pressure)
+                orientations.add(it.orientation)
+                tilts.add(it.getAxisValue(MotionEvent.AXIS_TILT))
+            }
+
+        assertThat(actualActions).containsExactlyElementsIn(expectedActions)
+        assertThat(actualPointerCounts).containsExactlyElementsIn(expectedPointerCounts)
+        assertThat(actualToolTypes).containsExactlyElementsIn(expectedToolTypes)
+        assertThat(actualTimes).containsExactlyElementsIn(expectedTimes)
+        assertThat(actualPositions).containsExactlyElementsIn(expectedPositions)
+        assertThat(actualPressures)
+            .comparingElementsUsing(floatListFuzzyEqual)
+            .containsExactlyElementsIn(expectedPressures)
+        assertThat(actualOrientations)
+            .comparingElementsUsing(floatListFuzzyEqual)
+            .containsExactlyElementsIn(expectedOrientations)
+        assertThat(actualTilts)
+            .comparingElementsUsing(floatListFuzzyEqual)
+            .containsExactlyElementsIn(expectedTilts)
+    }
+
+    @Test
+    fun pinchOut() {
+        val expectedActionsAndPointerIds =
+            listOf(
+                MotionEvent.ACTION_DOWN to 9000,
+                MotionEvent.ACTION_POINTER_DOWN to 9001,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_POINTER_UP to 9001,
+                MotionEvent.ACTION_UP to 9000,
+            )
+        val expectedPointerCounts = listOf(1, 2, 2, 2, 2, 2, 2, 1)
+        val expectedTimes = listOf(1000L, 1000L, 1010L, 1020L, 1030L, 1040L, 1050L, 1050L)
+        // Per pointer values: pointer ID 9000 goes down first and up last so has 2 extra events.
+        val expectedToolTypesForPointerIds =
+            mapOf(
+                9000 to List(8) { MotionEvent.TOOL_TYPE_FINGER },
+                9001 to
+                    listOf(
+                        null,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        null,
+                    ),
+            )
+        val expectedPositionsForPointerIds =
+            mapOf(
+                9000 to
+                    listOf(
+                        PointF(300F, 500F),
+                        PointF(300F, 500F),
+                        PointF(275F, 500F),
+                        PointF(250F, 500F),
+                        PointF(225F, 500F),
+                        PointF(200F, 500F),
+                        PointF(200F, 500F),
+                        PointF(200F, 500F),
+                    ),
+                9001 to
+                    listOf(
+                        null,
+                        PointF(500F, 500F),
+                        PointF(525F, 500F),
+                        PointF(550F, 500F),
+                        PointF(575F, 500F),
+                        PointF(600F, 500F),
+                        PointF(600F, 500F),
+                        null,
+                    ),
+            )
+
+        val actualActionsAndPointerIds = mutableListOf<Pair<Int, Int?>>()
+        val actualPointerCounts = mutableListOf<Int>()
+        val actualTimes = mutableListOf<Long>()
+        val actualToolTypesForPointerIds = mutableMapOf<Int, MutableList<Int?>>()
+        val actualPositionsForPointerIds = mutableMapOf<Int, MutableList<PointF?>>()
+        MultiTouchInputBuilder.pinchOutWithFactor(400F, 500F).runGestureWith {
+            actualActionsAndPointerIds.add(
+                Pair(
+                    it.actionMasked,
+                    if (it.actionMasked == MotionEvent.ACTION_MOVE) null
+                    else it.getPointerId(it.actionIndex),
+                )
+            )
+            actualPointerCounts.add(it.pointerCount)
+            actualTimes.add(it.eventTime)
+
+            val seenPointerIds = mutableSetOf<Int>()
+            for (pointerIndex in 0 until it.pointerCount) {
+                val pointerId = it.getPointerId(pointerIndex)
+                seenPointerIds.add(pointerId)
+                actualToolTypesForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(it.getToolType(pointerIndex))
+                actualPositionsForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(PointF(it.getX(pointerIndex), it.getY(pointerIndex)))
+            }
+            // Fill in per-pointer values for pointers not seen in this event with null.
+            for (pointerId in 9000..9001) {
+                if (!seenPointerIds.contains(pointerId)) {
+                    actualToolTypesForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                    actualPositionsForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                }
+            }
+        }
+
+        assertThat(actualActionsAndPointerIds)
+            .containsExactlyElementsIn(expectedActionsAndPointerIds)
+        assertThat(actualPointerCounts).containsExactlyElementsIn(expectedPointerCounts)
+        assertThat(actualTimes).containsExactlyElementsIn(expectedTimes)
+        assertThat(actualToolTypesForPointerIds)
+            .containsExactlyEntriesIn(expectedToolTypesForPointerIds)
+        assertThat(actualPositionsForPointerIds)
+            .containsExactlyEntriesIn(expectedPositionsForPointerIds)
+    }
+
+    @Test
+    fun pinchIn() {
+        val expectedActionsAndPointerIds =
+            listOf(
+                MotionEvent.ACTION_DOWN to 9000,
+                MotionEvent.ACTION_POINTER_DOWN to 9001,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_POINTER_UP to 9001,
+                MotionEvent.ACTION_UP to 9000,
+            )
+        val expectedPointerCounts = listOf(1, 2, 2, 2, 2, 2, 2, 1)
+        val expectedTimes = listOf(1000L, 1000L, 1010L, 1020L, 1030L, 1040L, 1050L, 1050L)
+        // Per pointer values: pointer ID 9000 goes down first and up last so has 2 extra events.
+        val expectedToolTypesForPointerIds =
+            mapOf(
+                9000 to List(8) { MotionEvent.TOOL_TYPE_FINGER },
+                9001 to
+                    listOf(
+                        null,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        null,
+                    ),
+            )
+        val expectedPositionsForPointerIds =
+            mapOf(
+                9000 to
+                    listOf(
+                        PointF(300F, 500F),
+                        PointF(300F, 500F),
+                        PointF(312.5F, 500F),
+                        PointF(325F, 500F),
+                        PointF(337.5F, 500F),
+                        PointF(350F, 500F),
+                        PointF(350F, 500F),
+                        PointF(350F, 500F),
+                    ),
+                9001 to
+                    listOf(
+                        null,
+                        PointF(500F, 500F),
+                        PointF(487.5F, 500F),
+                        PointF(475F, 500F),
+                        PointF(462.5F, 500F),
+                        PointF(450F, 500F),
+                        PointF(450F, 500F),
+                        null,
+                    ),
+            )
+
+        val actualActionsAndPointerIds = mutableListOf<Pair<Int, Int?>>()
+        val actualPointerCounts = mutableListOf<Int>()
+        val actualTimes = mutableListOf<Long>()
+        val actualToolTypesForPointerIds = mutableMapOf<Int, MutableList<Int?>>()
+        val actualPositionsForPointerIds = mutableMapOf<Int, MutableList<PointF?>>()
+        MultiTouchInputBuilder.pinchInWithFactor(400F, 500F).runGestureWith {
+            actualActionsAndPointerIds.add(
+                Pair(
+                    it.actionMasked,
+                    if (it.actionMasked == MotionEvent.ACTION_MOVE) null
+                    else it.getPointerId(it.actionIndex),
+                )
+            )
+            actualPointerCounts.add(it.pointerCount)
+            actualTimes.add(it.eventTime)
+
+            val seenPointerIds = mutableSetOf<Int>()
+            for (pointerIndex in 0 until it.pointerCount) {
+                val pointerId = it.getPointerId(pointerIndex)
+                seenPointerIds.add(pointerId)
+                actualToolTypesForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(it.getToolType(pointerIndex))
+                actualPositionsForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(PointF(it.getX(pointerIndex), it.getY(pointerIndex)))
+            }
+            // Fill in per-pointer values for pointers not seen in this event with null.
+            for (pointerId in 9000..9001) {
+                if (!seenPointerIds.contains(pointerId)) {
+                    actualToolTypesForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                    actualPositionsForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                }
+            }
+        }
+
+        assertThat(actualActionsAndPointerIds)
+            .containsExactlyElementsIn(expectedActionsAndPointerIds)
+        assertThat(actualPointerCounts).containsExactlyElementsIn(expectedPointerCounts)
+        assertThat(actualTimes).containsExactlyElementsIn(expectedTimes)
+        assertThat(actualToolTypesForPointerIds)
+            .containsExactlyEntriesIn(expectedToolTypesForPointerIds)
+        assertThat(actualPositionsForPointerIds)
+            .containsExactlyEntriesIn(expectedPositionsForPointerIds)
+    }
+
+    @Test
+    fun rotate90DegreesClockwise() {
+        val expectedActionsAndPointerIds =
+            listOf(
+                MotionEvent.ACTION_DOWN to 9000,
+                MotionEvent.ACTION_POINTER_DOWN to 9001,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_MOVE to null,
+                MotionEvent.ACTION_POINTER_UP to 9001,
+                MotionEvent.ACTION_UP to 9000,
+            )
+        val expectedPointerCounts = listOf(1, 2, 2, 2, 2, 2, 2, 1)
+        val expectedTimes = listOf(1000L, 1000L, 1010L, 1020L, 1030L, 1040L, 1050L, 1050L)
+        // Per pointer values: pointer ID 9000 goes down first and up last so has 2 extra events.
+        val expectedToolTypesForPointerIds =
+            mapOf(
+                9000 to List(8) { MotionEvent.TOOL_TYPE_FINGER },
+                9001 to
+                    listOf(
+                        null,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        MotionEvent.TOOL_TYPE_FINGER,
+                        null,
+                    ),
+            )
+        val expectedPositionsForPointerIds =
+            mapOf(
+                9000 to
+                    listOf(
+                        PointF(350F, 450F),
+                        PointF(350F, 450F),
+                        PointF(375F, 450F),
+                        PointF(400F, 450F),
+                        PointF(425F, 450F),
+                        PointF(450F, 450F),
+                        PointF(450F, 450F),
+                        PointF(450F, 450F),
+                    ),
+                9001 to
+                    listOf(
+                        null,
+                        PointF(450F, 550F),
+                        PointF(425F, 550F),
+                        PointF(400F, 550F),
+                        PointF(375F, 550F),
+                        PointF(350F, 550F),
+                        PointF(350F, 550F),
+                        null,
+                    ),
+            )
+
+        val actualActionsAndPointerIds = mutableListOf<Pair<Int, Int?>>()
+        val actualPointerCounts = mutableListOf<Int>()
+        val actualTimes = mutableListOf<Long>()
+        val actualToolTypesForPointerIds = mutableMapOf<Int, MutableList<Int?>>()
+        val actualPositionsForPointerIds = mutableMapOf<Int, MutableList<PointF?>>()
+        MultiTouchInputBuilder.rotate90DegreesClockwise(400F, 500F).runGestureWith {
+            actualActionsAndPointerIds.add(
+                Pair(
+                    it.actionMasked,
+                    if (it.actionMasked == MotionEvent.ACTION_MOVE) null
+                    else it.getPointerId(it.actionIndex),
+                )
+            )
+            actualPointerCounts.add(it.pointerCount)
+            actualTimes.add(it.eventTime)
+
+            val seenPointerIds = mutableSetOf<Int>()
+            for (pointerIndex in 0 until it.pointerCount) {
+                val pointerId = it.getPointerId(pointerIndex)
+                seenPointerIds.add(pointerId)
+                actualToolTypesForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(it.getToolType(pointerIndex))
+                actualPositionsForPointerIds
+                    .calculateIfAbsent(pointerId) { mutableListOf() }
+                    .add(PointF(it.getX(pointerIndex), it.getY(pointerIndex)))
+            }
+            // Fill in per-pointer values for pointers not seen in this event with null.
+            for (pointerId in 9000..9001) {
+                if (!seenPointerIds.contains(pointerId)) {
+                    actualToolTypesForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                    actualPositionsForPointerIds
+                        .calculateIfAbsent(pointerId) { mutableListOf() }
+                        .add(null)
+                }
+            }
+        }
+
+        assertThat(actualActionsAndPointerIds)
+            .containsExactlyElementsIn(expectedActionsAndPointerIds)
+        assertThat(actualPointerCounts).containsExactlyElementsIn(expectedPointerCounts)
+        assertThat(actualTimes).containsExactlyElementsIn(expectedTimes)
+        assertThat(actualToolTypesForPointerIds)
+            .containsExactlyEntriesIn(expectedToolTypesForPointerIds)
+        assertThat(actualPositionsForPointerIds)
+            .containsExactlyEntriesIn(expectedPositionsForPointerIds)
+    }
+
+    private val floatListFuzzyEqual: Correspondence<List<Float>, List<Float>> =
+        Correspondence.from(
+            { actualList: List<Float>?, expectedList: List<Float>? ->
+                if (expectedList == null || actualList == null)
+                    return@from actualList == expectedList
+                actualList
+                    .zip(expectedList) { actual, expected ->
+                        Correspondence.tolerance(0.001).compare(actual, expected)
+                    }
+                    .all { it }
+            },
+            "is approximately equal to",
+        )
+}
+
+/** Like [MutableMap.computeIfAbsent], but available on all API levels. */
+private fun <K, V> MutableMap<K, V>.calculateIfAbsent(key: K, mappingFunction: (K) -> V): V {
+    return get(key) ?: mappingFunction(key).also { put(key, it) }
+}
diff --git a/ink/ink-authoring/src/androidInstrumentedTest/res/values/themes.xml b/ink/ink-authoring/src/androidInstrumentedTest/res/values/themes.xml
new file mode 100644
index 0000000..7ca6da7
--- /dev/null
+++ b/ink/ink-authoring/src/androidInstrumentedTest/res/values/themes.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+
+Copyright (C) 2024 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<resources>
+
+<!-- Use this with full-screen UiAutomator screenshots to keep the focus on the
+   - content under test, to reduce low-signal golden changes. -->
+<style name="NoActionBar">
+  <item name="android:windowActionBar">false</item>
+  <item name="android:windowNoTitle">true</item>
+</style>
+
+</resources>
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/ExperimentalLatencyDataApi.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/ExperimentalLatencyDataApi.kt
new file mode 100644
index 0000000..cd917e4
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/ExperimentalLatencyDataApi.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Marks declarations that are are part of the **experimental** Ink Latency Data API. These
+ * declarations may (or may not) be changed, deprecated, or removed in the near future, or the
+ * semantics of their behavior may change in some way that may break some code.
+ *
+ * You can opt in to using APIs in your code by marking your declaration with `@OptIn` passing the
+ * opt-in requirement annotation as its argument: `@OptIn(ExperimentalLatencyDataApi::class)`.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@MustBeDocumented
+@Retention(value = AnnotationRetention.BINARY)
+@Target(
+    AnnotationTarget.CLASS,
+    AnnotationTarget.ANNOTATION_CLASS,
+    AnnotationTarget.PROPERTY,
+    AnnotationTarget.FIELD,
+    AnnotationTarget.LOCAL_VARIABLE,
+    AnnotationTarget.VALUE_PARAMETER,
+    AnnotationTarget.CONSTRUCTOR,
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.PROPERTY_SETTER,
+    AnnotationTarget.TYPEALIAS,
+)
+@RequiresOptIn(level = RequiresOptIn.Level.ERROR)
+public annotation class ExperimentalLatencyDataApi
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokeId.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokeId.kt
new file mode 100644
index 0000000..41fe5c3
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokeId.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Stroke identifier, unique within an app's lifetime.
+ *
+ * Can be used for equality checks and as a map key.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+@Suppress("ClassShouldBeObject") // Multiple instances of this class are required as IDs.
+public class InProgressStrokeId @VisibleForTesting public constructor() {
+
+    internal companion object {
+        // If VisibleForTesting supported otherwise=INTERNAL (b/174783094), then the constructor
+        // could
+        // serve both testing use cases as well as being called from code in the same module but a
+        // different package (e.g. LatencyData).
+        internal fun create(): InProgressStrokeId = InProgressStrokeId()
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesFinishedListener.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesFinishedListener.kt
new file mode 100644
index 0000000..541484b
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesFinishedListener.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.UiThread
+import androidx.ink.strokes.Stroke
+
+/**
+ * Notifies the client app when a [LegacyStroke] or [Stroke] (or more than one) has been completed.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+@UiThread
+public interface InProgressStrokesFinishedListener {
+    /**
+     * Called when there are no longer any in-progress strokes. All strokes that were in progress
+     * simultaneously will be delivered in the same callback. This callback will execute on the UI
+     * thread. The implementer should prepare to start rendering the given strokes in their own
+     * [android.view.View]. To do that, the strokes should be saved in a variable where they will be
+     * picked up in a view's next call to [android.view.View.onDraw], and that view's
+     * [android.view.View.invalidate] should be called. When that happens, in the same UI thread run
+     * loop (HWUI frame), [InProgressStrokesView.removeFinishedStrokes] should be called with the
+     * IDs of the strokes that are now being rendered in the other view. Failure to adhere to these
+     * guidelines will result in brief rendering errors between this view and the client app's
+     * view - either a gap where the stroke is not drawn during a frame, or a double draw where the
+     * stroke is drawn twice and translucent strokes appear more opaque than they should.
+     */
+    public fun onStrokesFinished(strokes: Map<InProgressStrokeId, Stroke>) {}
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesView.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesView.kt
new file mode 100644
index 0000000..0e2450c
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/InProgressStrokesView.kt
@@ -0,0 +1,714 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Path
+import android.os.Build
+import android.util.AttributeSet
+import android.util.Log
+import android.view.MotionEvent
+import android.view.View
+import android.widget.FrameLayout
+import androidx.annotation.AttrRes
+import androidx.annotation.RestrictTo
+import androidx.annotation.UiThread
+import androidx.annotation.VisibleForTesting
+import androidx.ink.authoring.internal.CanvasInProgressStrokesRenderHelperV21
+import androidx.ink.authoring.internal.CanvasInProgressStrokesRenderHelperV29
+import androidx.ink.authoring.internal.CanvasInProgressStrokesRenderHelperV33
+import androidx.ink.authoring.internal.FinishedStroke
+import androidx.ink.authoring.internal.InProgressStrokesManager
+import androidx.ink.authoring.internal.InProgressStrokesRenderHelper
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.authoring.latency.LatencyDataCallback
+import androidx.ink.brush.Brush
+import androidx.ink.brush.ExperimentalInkCustomBrushApi
+import androidx.ink.rendering.android.TextureBitmapStore
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.Stroke
+import androidx.ink.strokes.StrokeInput
+import androidx.ink.strokes.StrokeInputBatch
+import androidx.test.espresso.idling.CountingIdlingResource
+import java.util.concurrent.TimeUnit
+import kotlin.math.hypot
+
+// See https://www.nist.gov/pml/owm/si-units-length
+private const val CM_PER_INCH = 2.54f
+
+/** Displays in-progress ink strokes as [MotionEvent] user inputs are provided to it. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+@OptIn(ExperimentalLatencyDataApi::class)
+public class InProgressStrokesView
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
+    FrameLayout(context, attrs, defStyleAttr) {
+
+    /**
+     * Force the HWUI-based high latency implementation to be used under the hood, even if the
+     * system supports low latency inking. This takes precedence over [useNewTPlusRenderHelper]. The
+     * only reason a developer may want to do this today is if they have a hard product requirement
+     * for wet ink strokes to appear in z order between two different HWUI elements - e.g. above a
+     * canvas of content but below a floating overlay button/toolbar. This is deprecated because
+     * tools to achieve layered rendering are being developed, and soon this API to force high
+     * latency wet rendering will be removed in favor of using the best rendering strategy that the
+     * device OS level allows.
+     *
+     * This must be set to its desired value before the first call to [startStroke] or [eagerInit].
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+    @Deprecated("Prefer to allow the underlying implementation details to be chosen automatically.")
+    public var useHighLatencyRenderHelper: Boolean = false
+
+    /**
+     * Opt into using a new implementation of the wet rendering strategy that is compatible with
+     * Android T (API 33) and above. This flag is only temporary to allow for safe, gradual rollout,
+     * and will be removed when [CanvasInProgressStrokesRenderHelperV33] is fully rolled out.
+     * [useHighLatencyRenderHelper] takes precedence over this.
+     *
+     * This must be set to its desired value before the first call to [startStroke] or [eagerInit].
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+    @Deprecated("Prefer to allow the underlying implementation details to be chosen automatically.")
+    public var useNewTPlusRenderHelper: Boolean = false
+
+    /**
+     * Set a minimum delay from when the user finishes a stroke until rendering is handed off to the
+     * client's dry layer via [InProgressStrokesFinishedListener.onStrokesFinished]. This value
+     * would ideally be long enough that quick subsequent strokes - such as for fast handwriting -
+     * are processed and later handed off as one group, but short enough that the handoff can take
+     * place during short, natural pauses in handwriting.
+     *
+     * If handoff is ever needed as soon as safely possible, call [requestHandoff].
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+    public var handoffDebounceTimeMs: Long = 0L
+        @UiThread
+        set(value) {
+            require(value >= 0L) { "Debounce time must not be negative, received $value" }
+            field = value
+            // Don't force initialization to set this value, otherwise the properties that must be
+            // set
+            // before initialization would be harder to set. Hold onto it and pass it down to the
+            // InProgressStrokesManager when it gets initialized.
+            if (isInitialized()) {
+                inProgressStrokesManager.setHandoffDebounceTimeMs(value)
+            }
+        }
+
+    /**
+     * [TextureBitmapStore] used by the default value for [rendererFactory].
+     *
+     * By default, this is a no-op implementation that does not load any brush textures. The factory
+     * functions are called when the renderer is initialized, so if this will be changed to
+     * something that does load and store texture images, it must be set before the first call to
+     * [startStroke] or [eagerInit].
+     */
+    // Needed on both property and on getter for AndroidX build, but the Kotlin compiler doesn't
+    // like it on the getter so suppress its complaint.
+    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+    @ExperimentalInkCustomBrushApi
+    @get:ExperimentalInkCustomBrushApi
+    @set:ExperimentalInkCustomBrushApi
+    public var textureBitmapStore: TextureBitmapStore = TextureBitmapStore { null }
+        set(value) {
+            check(!isInitialized()) { "Cannot set textureBitmapStore after initialization." }
+            field = value
+        }
+
+    /**
+     * A function that creates a [CanvasStrokeRenderer] when invoked. The default implementation of
+     * this will automatically account for the Android OS version of the device. If you choose to
+     * replace the default with an alternate implementation, then you must set this variable before
+     * the first call to [startStroke] or [eagerInit].
+     */
+    public var rendererFactory: () -> CanvasStrokeRenderer = {
+        @OptIn(ExperimentalInkCustomBrushApi::class) CanvasStrokeRenderer.create(textureBitmapStore)
+    }
+        set(value) {
+            check(!isInitialized()) { "Cannot set rendererFactory after initialization." }
+            field = value
+        }
+
+    /**
+     * Denote an area of this [InProgressStrokesView] where no ink should be visible. This is useful
+     * for UI elements that float on top of (in Z order) the drawing surface - without this, a user
+     * would be able to draw in-progress ("wet") strokes on top of those UI elements, but then when
+     * the stroke is finished, it will appear as a dry stroke underneath of the UI element. If this
+     * mask is set to the shape and position of the floating UI element, then the ink will never be
+     * rendered in that area, making it appear as if it's being drawn underneath the UI element.
+     *
+     * This technique is most convincing when the UI element is opaque. Often there are parts of the
+     * UI element that are translucent, such as drop shadows, or anti-aliasing along the edges. The
+     * result will look a little different between wet and dry strokes for those cases, but it can
+     * be a worthwhile tradeoff compared to the alternative of drawing wet strokes on top of that UI
+     * element.
+     */
+    public var maskPath: Path? = null
+        set(value) {
+            field = value
+            renderHelper?.maskPath = value
+        }
+
+    /**
+     * The transform matrix to convert [MotionEvent] coordinates, as passed to [startStroke],
+     * [addToStroke], and [finishStroke], into coordinates of this [InProgressStrokesView] for
+     * rendering. Defaults to the identity matrix, for the recommended case where
+     * [InProgressStrokesView] exactly overlays the [android.view.View] that has the touch listener
+     * from which [MotionEvent] instances are being forwarded.
+     */
+    public var motionEventToViewTransform: Matrix = Matrix()
+        set(value) {
+            field.set(value)
+            // Don't force initialization to set this value, otherwise the properties that must be
+            // set
+            // before initialization would be harder to set. Hold onto it and pass it down to the
+            // InProgressStrokesManager when it gets initialized.
+            if (isInitialized()) {
+                inProgressStrokesManager.motionEventToViewTransform = value
+            }
+        }
+
+    /**
+     * Allows a test to easily wait until all in-progress strokes are completed and handed off.
+     * There is no reason to set this in non-test code. The recommended approach is to include this
+     * small object within production code, but actually registering it and making use of it would
+     * be exclusive to test code.
+     *
+     * https://developer.android.com/training/testing/espresso/idling-resource#integrate-recommended-approach
+     */
+    public var inProgressStrokeCounter: CountingIdlingResource? = null
+        set(value) {
+            field = value
+            // Don't force initialization to set this value, otherwise the properties that must be
+            // set
+            // before initialization would be harder to set. Hold onto it and pass it down to the
+            // InProgressStrokesManager when it gets initialized.
+            if (isInitialized()) {
+                inProgressStrokesManager.inProgressStrokeCounter = value
+            }
+        }
+
+    /**
+     * An optional callback for reporting latency of the processing of input events for in-progress
+     * strokes. Clients may implement the [LatencyDataCallback] interface and set this field to
+     * receive latency measurements.
+     *
+     * Notes for clients: Do not hold references to the [LatencyData] passed into this callback.
+     * After this callback returns, the [LatencyData] instance will immediately become invalid: it
+     * will be deleted or recycled. Also, to avoid stalling the UI thread, implementers should
+     * minimize the amount of computation in this callback, and should also avoid allocations (since
+     * allocation may trigger the garbage collector).
+     */
+    // Needed on both property and on getter for AndroidX build, but the Kotlin compiler doesn't
+    // like it on the getter so suppress its complaint.
+    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+    @ExperimentalLatencyDataApi
+    @get:ExperimentalLatencyDataApi
+    @set:ExperimentalLatencyDataApi
+    public var latencyDataCallback: LatencyDataCallback? = null
+
+    private val renderHelperCallback =
+        object : InProgressStrokesRenderHelper.Callback {
+
+            override fun onDraw() = inProgressStrokesManager.onDraw()
+
+            override fun onDrawComplete() = inProgressStrokesManager.onDrawComplete()
+
+            override fun reportEstimatedPixelPresentationTime(timeNanos: Long) =
+                inProgressStrokesManager.reportEstimatedPixelPresentationTime(timeNanos)
+
+            override fun setCustomLatencyDataField(setter: (LatencyData, Long) -> Unit) =
+                inProgressStrokesManager.setCustomLatencyDataField(setter)
+
+            override fun handOffAllLatencyData() = inProgressStrokesManager.handOffAllLatencyData()
+
+            override fun setPauseStrokeCohortHandoffs(paused: Boolean) =
+                inProgressStrokesManager.setPauseStrokeCohortHandoffs(paused)
+
+            override fun onStrokeCohortHandoffToHwui(
+                strokeCohort: Map<InProgressStrokeId, FinishedStroke>
+            ) = inProgressStrokesManager.onStrokeCohortHandoffToHwui(strokeCohort)
+
+            override fun onStrokeCohortHandoffToHwuiComplete() =
+                inProgressStrokesManager.onStrokeCohortHandoffToHwuiComplete()
+        }
+
+    private val finishedStrokesListeners = mutableSetOf<InProgressStrokesFinishedListener>()
+
+    private val finishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>()
+
+    // Most callers can use inProgressStrokesManager, but isInitialized() needs direct access to the
+    // delegate's isInitialized method.
+    private val inProgressStrokesManagerDelegate = lazy {
+        InProgressStrokesManager(
+                inProgressStrokesRenderHelper(),
+                ::postOnAnimation,
+                ::post,
+                // When InProgressStrokesManager calls back to report a LatencyData, report it in
+                // turn to
+                // the client using the callback that they provided.
+                latencyDataCallback = { latencyDataCallback?.onLatencyData(it) },
+            )
+            .also {
+                it.addListener(inProgressStrokesManagerListener)
+                // While initializing the InProgressStrokesManager, pass along any properties that
+                // had been
+                // set pre-initialization.
+                it.motionEventToViewTransform = motionEventToViewTransform
+                it.inProgressStrokeCounter = inProgressStrokeCounter
+                it.setHandoffDebounceTimeMs(handoffDebounceTimeMs)
+            }
+    }
+    private val inProgressStrokesManager by inProgressStrokesManagerDelegate
+
+    private val inProgressStrokesManagerListener =
+        object : InProgressStrokesManager.Listener {
+            override fun onAllStrokesFinished(strokes: Map<InProgressStrokeId, FinishedStroke>) {
+                finishedStrokesView.addStrokes(strokes)
+
+                val newlyFinishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>()
+                for ((strokeId, finishedStroke) in strokes) {
+                    newlyFinishedStrokes[strokeId] = finishedStroke.stroke
+                }
+
+                finishedStrokes.putAll(newlyFinishedStrokes)
+                for (listener in finishedStrokesListeners) {
+
+                    listener.onStrokesFinished(newlyFinishedStrokes)
+                }
+            }
+        }
+
+    private var renderHelper: InProgressStrokesRenderHelper? = null
+
+    private val finishedStrokesView =
+        FinishedStrokesView(
+            context,
+            createRenderer = rendererFactory,
+        )
+
+    /** Allows subclasses to provide their implementation of [InProgressStrokesRenderHelper]. */
+    private fun inProgressStrokesRenderHelper(): InProgressStrokesRenderHelper {
+        val existingInstance = renderHelper
+        if (existingInstance != null) return existingInstance
+
+        val renderer = rendererFactory()
+
+        @Suppress("ObsoleteSdkInt") // TODO(b/262911421): Should not need to suppress.
+        val result =
+            @Suppress("DEPRECATION")
+            if (useHighLatencyRenderHelper) {
+                CanvasInProgressStrokesRenderHelperV21(
+                    this,
+                    renderHelperCallback,
+                    renderer,
+                )
+            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                if (useNewTPlusRenderHelper) {
+                    CanvasInProgressStrokesRenderHelperV33(
+                        this,
+                        renderHelperCallback,
+                        renderer,
+                    )
+                } else {
+                    // Newer OS versions on Lenovo P12 Pro hit an issue with the v29 implementation
+                    // of the
+                    // offscreen frame buffer. It works fine on the v33 implementation, but if the
+                    // v33 version
+                    // is not enabled, use the v29 version without the offscreen frame buffer.
+                    CanvasInProgressStrokesRenderHelperV29(
+                        this,
+                        renderHelperCallback,
+                        renderer,
+                        useOffScreenFrameBuffer = false,
+                    )
+                }
+            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                CanvasInProgressStrokesRenderHelperV29(
+                    this,
+                    renderHelperCallback,
+                    renderer,
+                    useOffScreenFrameBuffer = true,
+                )
+            } else {
+                CanvasInProgressStrokesRenderHelperV21(
+                    this,
+                    renderHelperCallback,
+                    renderer,
+                )
+            }
+
+        result.maskPath = maskPath
+        renderHelper = result
+
+        return result
+    }
+
+    private fun isInitialized() = inProgressStrokesManagerDelegate.isInitialized()
+
+    /**
+     * Add a listener to be notified when strokes are finished. These strokes will continue to be
+     * rendered within this view until [removeFinishedStrokes] is called. All of the strokes that
+     * have been delivered to listeners but have not yet been removed with [removeFinishedStrokes]
+     * are available through [getFinishedStrokes].
+     */
+    public fun addFinishedStrokesListener(listener: InProgressStrokesFinishedListener) {
+        finishedStrokesListeners.add(listener)
+    }
+
+    /** Removes a listener that had previously been added with [addFinishedStrokesListener]. */
+    public fun removeFinishedStrokesListener(listener: InProgressStrokesFinishedListener) {
+        finishedStrokesListeners.remove(listener)
+    }
+
+    /**
+     * Eagerly initialize rather than waiting for the first stroke to be drawn. Since initialization
+     * can be somewhat heavyweight, doing this as soon as it's likely for the user to start drawing
+     * can prevent initialization from introducing latency to the first stroke.
+     */
+    public fun eagerInit() {
+        // Getting the lazy value kicks off its initialization.
+        @Suppress("UNUSED_VARIABLE") val unused = inProgressStrokesManager
+    }
+
+    /**
+     * Start building a stroke with the [event] data at [pointerIndex].
+     *
+     * @param event The first [MotionEvent] as part of a Stroke's input data, typically an
+     *   ACTION_DOWN.
+     * @param pointerIndex The index of the relevant pointer in the [event].
+     * @param motionEventToWorldTransform The matrix that transforms [event] coordinates into the
+     *   client app's "world" coordinates, which typically is defined by how a client app's document
+     *   is panned/zoomed/rotated. This defaults to the identity matrix, in which case the world
+     *   coordinate space is the same as the [MotionEvent] coordinates, but the caller should pass
+     *   in their own value reflecting a coordinate system that is independent of the device's pixel
+     *   density (e.g. scaled by 1 / [android.util.DisplayMetrics.density]) and any pan/zoom/rotate
+     *   gestures that have been applied to the "camera" which portrays the "world" on the device
+     *   screen. This matrix must be invertible.
+     * @param strokeToWorldTransform An optional matrix that transforms this stroke into the client
+     *   app's "world" coordinates, which allows the coordinates of the stroke to be defined in
+     *   something other than world coordinates. Defaults to the identity matrix, in which case the
+     *   stroke coordinate space is the same as world coordinate space. This matrix must be
+     *   invertible.
+     * @param brush Brush specification for the stroke being started. Note that if
+     *   [motionEventToWorldTransform] and [strokeToWorldTransform] combine to a [MotionEvent] to
+     *   stroke coordinates transform that scales stroke coordinate units to be very different in
+     *   size than screen pixels, then it is recommended to update the value of [Brush.epsilon] to
+     *   reflect that.
+     * @return The Stroke ID of the stroke being built, later used to identify which stroke is being
+     *   added to, finished, or canceled.
+     * @throws IllegalArgumentException if [motionEventToWorldTransform] or [strokeToWorldTransform]
+     *   is not invertible.
+     */
+    @JvmOverloads
+    public fun startStroke(
+        event: MotionEvent,
+        pointerIndex: Int,
+        brush: Brush,
+        motionEventToWorldTransform: Matrix = Matrix(),
+        strokeToWorldTransform: Matrix = Matrix(),
+    ): InProgressStrokeId =
+        inProgressStrokesManager.startStroke(
+            event,
+            pointerIndex,
+            motionEventToWorldTransform,
+            strokeToWorldTransform,
+            brush,
+            strokeUnitLengthCm =
+                strokeUnitLengthCm(motionEventToWorldTransform, strokeToWorldTransform),
+        )
+
+    private fun strokeUnitLengthCm(
+        motionEventToWorldTransform: Matrix,
+        strokeToWorldTransform: Matrix,
+    ): Float {
+        val strokeToCmTransform =
+            Matrix().also {
+                // Compute (world -> MotionEvent) = (MotionEvent -> world)^-1
+                require(motionEventToWorldTransform.invert(it)) {
+                    "motionEventToWorldTransform must be invertible, but was $motionEventToWorldTransform"
+                }
+                // Compute (stroke -> MotionEvent) = (world -> MotionEvent) * (stroke -> world)
+                it.preConcat(strokeToWorldTransform)
+                // Compute (stroke -> cm) = (MotionEvent -> cm) * (stroke -> MotionEvent)
+                // This assumes that MotionEvent's coordinate space is hardware pixels.
+                val metrics = context.resources.displayMetrics
+                it.postScale(CM_PER_INCH / metrics.xdpi, CM_PER_INCH / metrics.ydpi)
+            }
+        // Compute the scaling factor that is being applied by the (stroke -> cm) transform. If the
+        // transform is isotropic (which it should be, unless the client app is doing something
+        // weird),
+        // then the vertical and horizontal scaling factors will be the same, but just in case
+        // they're
+        // not, average them together.
+        val values = FloatArray(9)
+        strokeToCmTransform.getValues(values)
+        return 0.5f * (hypot(values[0], values[1]) + hypot(values[3], values[4]))
+    }
+
+    /**
+     * Start building a stroke with the provided [input].
+     *
+     * @param input The [StrokeInput] that started a stroke.
+     * @param brush Brush specification for the stroke being started. Note that if
+     *   [motionEventToWorldTransform] and [strokeToWorldTransform] combine to a [MotionEvent] to
+     *   stroke coordinates transform that scales stroke coordinate units to be very different in
+     *   size than screen pixels, then it is recommended to update the value of [Brush.epsilon] to
+     *   reflect that.
+     * @return The Stroke ID of the stroke being built, later used to identify which stroke is being
+     *   added to, finished, or canceled.
+     */
+    public fun startStroke(input: StrokeInput, brush: Brush): InProgressStrokeId =
+        inProgressStrokesManager.startStroke(input, brush)
+
+    /**
+     * Add [event] data at [pointerIndex] to already started stroke with [strokeId].
+     *
+     * @param event the next [MotionEvent] as part of a Stroke's input data, typically an
+     *   ACTION_MOVE.
+     * @param pointerIndex the index of the relevant pointer in the [event].
+     * @param strokeId the Stroke that is to be built upon with [event].
+     * @param prediction optional predicted [MotionEvent] containing predicted inputs between event
+     *   and the time of the next frame, as generated by
+     *   [androidx.input.motionprediction.MotionEventPredictor.predict].
+     */
+    @JvmOverloads
+    public fun addToStroke(
+        event: MotionEvent,
+        pointerIndex: Int,
+        strokeId: InProgressStrokeId,
+        prediction: MotionEvent? = null,
+    ): Unit =
+        inProgressStrokesManager.addToStroke(
+            event,
+            pointerIndex,
+            strokeId,
+            makeCorrectPrediction(prediction),
+        )
+
+    /**
+     * Add [inputs] to already started stroke with [strokeId].
+     *
+     * @param inputs the next [StrokeInputBatch] to be added to the stroke.
+     * @param strokeId the Stroke that is to be built upon with [inputs].
+     * @param prediction optional [StrokeInputBatch] containing predicted inputs after this portion
+     *   of the stroke.
+     */
+    @JvmOverloads
+    public fun addToStroke(
+        inputs: StrokeInputBatch,
+        strokeId: InProgressStrokeId,
+        prediction: StrokeInputBatch = ImmutableStrokeInputBatch.EMPTY,
+    ): Unit = inProgressStrokesManager.addToStroke(inputs, strokeId, prediction)
+
+    /**
+     * Temporary helper to clean prediction input to avoid crashing on multi-pointer draw. Remove
+     * once prediction motionevents are cleaned up.
+     *
+     * TODO b/306361370 - Remove this function when prediction motionevents contain clean eventtime
+     * data.
+     */
+    private fun makeCorrectPrediction(event: MotionEvent?): MotionEvent? {
+        if (event == null) return null
+        if (event.eventTime == 0L) {
+            Log.e(
+                "InProgressStrokesView",
+                "prediction motionevent has eventTime = 0L and is being ignored.",
+            )
+            return null
+        }
+        for (index in 0 until event.historySize) {
+            if (event.getHistoricalEventTime(index) == 0L) {
+                Log.e(
+                    "InProgressStrokesView",
+                    "Prediction motionevent has historicalEventTime[$index] = 0L and is being ignored.",
+                )
+                return null
+            }
+        }
+        return event
+    }
+
+    /**
+     * Complete the building of a stroke.
+     *
+     * @param event the last [MotionEvent] as part of a stroke, typically an ACTION_UP.
+     * @param pointerIndex the index of the relevant pointer.
+     * @param strokeId the stroke that is to be finished with the latest event.
+     */
+    public fun finishStroke(
+        event: MotionEvent,
+        pointerIndex: Int,
+        strokeId: InProgressStrokeId,
+    ): Unit = inProgressStrokesManager.finishStroke(event, pointerIndex, strokeId)
+
+    /**
+     * Complete the building of a stroke.
+     *
+     * @param input the last [StrokeInput] in the stroke.
+     * @param strokeId the stroke that is to be finished with the latest event.
+     */
+    public fun finishStroke(input: StrokeInput, strokeId: InProgressStrokeId): Unit =
+        inProgressStrokesManager.finishStroke(input, strokeId)
+
+    /**
+     * Cancel the building of a stroke.
+     *
+     * @param strokeId the stroke to cancel.
+     * @param event The [MotionEvent] that led to this cancellation, if applicable.
+     */
+    @JvmOverloads
+    public fun cancelStroke(strokeId: InProgressStrokeId, event: MotionEvent? = null): Unit =
+        inProgressStrokesManager.cancelStroke(strokeId, event)
+
+    /**
+     * Request that [handoffDebounceTimeMs] be temporarily ignored to hand off rendering to the
+     * client's dry layer via [InProgressStrokesFinishedListener.onStrokesFinished]. This will be
+     * done as soon as safely possible, still at a time when a rendering flicker can be avoided.
+     * Afterwards, handoff debouncing will resume as normal.
+     *
+     * This API is experimental for now, as one approach to address start-of-stroke latency for fast
+     * subsequent strokes.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+    public fun requestHandoff(): Unit = inProgressStrokesManager.requestImmediateHandoff()
+
+    /**
+     * Make a best effort to end all currently in progress strokes, which will include a callback to
+     * [InProgressStrokesFinishedListener.onStrokesFinished] during this function's execution if
+     * there are any strokes to hand off. In normal operation, prefer to call [finishStroke] or
+     * [cancelStroke] for each of your in progress strokes and wait for the callback to
+     * [InProgressStrokesFinishedListener.onStrokesFinished], possibly accelerated by
+     * [requestHandoff] if you have set a non-zero value for [handoffDebounceTimeMs]. This function
+     * is for situations where an immediate shutdown is necessary, such as
+     * [android.app.Activity.onPause]. This must be called on the UI thread, and will block it for
+     * up to [timeoutMillis] milliseconds. Note that if this is called when the app is still visible
+     * on screen, then the visual behavior is undefined - the stroke content may flicker.
+     *
+     * @param cancelAllInProgress If `true`, treat any unfinished strokes as if you called
+     *   [cancelStroke] with their [InProgressStrokeId], so they will not be visible and not
+     *   included in the return value of [getFinishedStrokes]. If `false`, treat unfinished strokes
+     *   as if you called [finishStroke] with their [InProgressStrokeId], which will keep them
+     *   visible and included in the return value of [getFinishedStrokes].
+     * @param timeout The maximum time that will be spent waiting before returning. If this is not
+     *   positive, then this will not wait at all.
+     * @param timeoutUnit The [TimeUnit] for [timeout].
+     * @return `true` if and only if the flush completed successfully. Note that not all
+     *   configurations support flushing, and flushing is best effort, so this is not guaranteed to
+     *   return `true`.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+    public fun flush(
+        timeout: Long,
+        timeoutUnit: TimeUnit,
+        cancelAllInProgress: Boolean = false,
+    ): Boolean {
+        if (!isInitialized()) {
+            // Nothing to flush if it's not initialized.
+            return true
+        }
+        return inProgressStrokesManager.flush(timeout, timeoutUnit, cancelAllInProgress)
+    }
+
+    /**
+     * For testing only. Wait up to [timeout] ([timeoutUnit]) until the queued actions have all been
+     * processed. This must be called on the UI thread, and blocks it to run synchronously. This is
+     * useful for tests to know that certain events have been processed, to be able to assert that a
+     * screenshot will look a certain way, or that certain callbacks should be scheduled/delivered.
+     * Do not call this from production code.
+     *
+     * In some ways this is similar to [flush], which is intended for production use in certain
+     * circumstances.
+     */
+    @VisibleForTesting
+    internal fun sync(timeout: Long, timeoutUnit: TimeUnit) {
+        if (isInitialized()) {
+            // Nothing to sync if it's not initialized.
+            inProgressStrokesManager.sync(timeout, timeoutUnit)
+        }
+    }
+
+    /**
+     * Returns all the finished strokes that are still being rendered by this view. The IDs of these
+     * strokes should be passed to [removeFinishedStrokes] when they are handed off to another view.
+     */
+    public fun getFinishedStrokes(): Map<InProgressStrokeId, Stroke> {
+        return finishedStrokes
+    }
+
+    /**
+     * Stop this view from rendering the strokes with the given IDs.
+     *
+     * This should be called in the same UI thread run loop (HWUI frame) as when the strokes start
+     * being rendered elsewhere in the view hierarchy. This means they are saved in a location where
+     * they will be picked up in a view's next call to [onDraw], and that view's [invalidate] method
+     * has been called. If these two operations are not done within the same UI thread run loop
+     * (usually side by side - see example below), then there will be brief rendering errors -
+     * either a visual gap where the stroke is not drawn during a frame, or a double draw where the
+     * stroke is drawn twice and translucent strokes appear more opaque than they should.
+     */
+    public fun removeFinishedStrokes(strokeIds: Set<InProgressStrokeId>) {
+        for (id in strokeIds) finishedStrokes.remove(id)
+        finishedStrokesView.removeStrokes(strokeIds)
+    }
+
+    public override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        addView(finishedStrokesView)
+    }
+}
+
+/**
+ * Renders finished strokes until the client says they are ready to render the strokes themselves
+ * with [InProgressStrokesView.removeFinishedStrokes].
+ */
+@SuppressLint("ViewConstructor") // Not inflated through XML
+private class FinishedStrokesView(
+    context: Context,
+    attrs: AttributeSet? = null,
+    @AttrRes defStyleAttr: Int = 0,
+    // Lazy, since many clients will call removeFinishedStrokes immediately with the callback and
+    // never need to render strokes within this holding view.
+    createRenderer: () -> CanvasStrokeRenderer,
+) : View(context, attrs, defStyleAttr) {
+
+    private val renderer by lazy(createRenderer)
+
+    private val finishedStrokes = mutableMapOf<InProgressStrokeId, FinishedStroke>()
+
+    fun addStrokes(strokes: Map<InProgressStrokeId, FinishedStroke>) {
+        finishedStrokes.putAll(strokes)
+        invalidate()
+    }
+
+    fun removeStrokes(strokeIds: Set<InProgressStrokeId>) {
+        for (strokeId in strokeIds) finishedStrokes.remove(strokeId)
+        invalidate()
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        @Suppress("UNUSED_VARIABLE")
+        for ((strokeId, finishedStroke) in finishedStrokes) {
+            renderer.draw(canvas, finishedStroke.stroke, finishedStroke.strokeToViewTransform)
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUp.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUp.kt
new file mode 100644
index 0000000..62cfde9
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/AtMostOnceAfterSetUp.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import androidx.annotation.CheckResult
+
+/**
+ * A utility that creates [Runnable] objects that can be [run][Runnable.run] more than once, with
+ * the guarantee that [callback] is executed only once.
+ *
+ * It operates in a cycle that alternates between two distinct phases - a setup phase and an
+ * execution phase. The setup phase consists of calling [setUp] one or more times and registering
+ * each resulting [Runnable] to be [run][Runnable.run] later. The execution phase is when those
+ * [Runnable] objects are [run][Runnable.run], de-duplicated such that [callback] is executed only
+ * once. After this, a new cycle is begun by entering the setup phase again.
+ *
+ * This is useful with a callback framework that doesn't deduplicate the [Runnable] instances that
+ * it is given (e.g. [android.view.View.postOnAnimation]), initiated from call sites where there is
+ * not an obvious boundary between the setup phase and the execution phase (e.g. event handlers like
+ * [android.view.View.onTouchEvent]).
+ *
+ * For example, to perform some action on Android once per frame when there has been some user
+ * input, after all the input for the frame has been processed:
+ * ```
+ * val doAtEndOfFrame = Runnable {
+ *   // Non-idempotent logic that should only be run once per frame.
+ *   // ...
+ * }
+ * val doOnceAtEndOfFrame = AtMostOnceAfterSetUp(doAtEndOfFrame)
+ *
+ * fun onTouch(v: View, event: MotionEvent) {
+ *   // Some touch handling logic.
+ *
+ *   // Incorrect - would potentially execute the non-idempotent logic multiple times per frame,
+ *   // once for every call to `onTouch` within the frame.
+ *   // v.postOnAnimation(doAtEndOfFrame)
+ *
+ *   // Correct - if `onTouch` is called multiple times within this frame, or even if this line is
+ *   // repeated multiple times within this `onTouch` call, the non-idempotent logic in
+ *   // `doAtEndOfFrame` will be executed just once.
+ *   v.postOnAnimation(doOnceAtEndOfFrame.setUp())
+ * }
+ * ```
+ */
+internal class AtMostOnceAfterSetUp(private val callback: () -> Unit) {
+
+    /**
+     * Flipped to `true` by [setUp] to allow [callback] to execute again, and flipped back to
+     * `false` when [callback] is run to allow for another cycle.
+     */
+    private var isSetUp = false
+
+    /**
+     * This will be [run][Runnable.run] once for each call to [setUp], so use [isSetUp] state to
+     * ensure that [callback] is executed only once despite potentially multiple calls to [setUp]
+     * and therefore multiple [runs][Runnable.run] of this [Runnable].
+     */
+    private val runnable = Runnable {
+        // Do nothing if `callback` has already been executed.
+        if (!isSetUp) return@Runnable
+
+        // `callback` is being executed, so set the flag to ensure it cannot be executed again until
+        // `setUp` is called again in the setup phase of the next cycle.
+        isSetUp = false
+        callback()
+    }
+
+    /**
+     * Returns a [Runnable], which when [run][Runnable.run], executes [callback] if [callback]
+     * hasn't yet been executed, or does nothing if [callback] has already been executed by a
+     * previous call to [Runnable.run]. After [callback] has been executed, a subsequent call to
+     * [setUp] will start the cycle anew and allow [callback] to be executed as a response to one or
+     * more [setUp] calls (and [running][Runnable.run] their resulting [Runnable] objects).
+     *
+     * Be sure to use the return value [Runnable], for example by registering it with
+     * [android.view.View.postOnAnimation].
+     */
+    @CheckResult
+    fun setUp(): Runnable {
+        // Mark `callback` as able to be executed, but just once until `setUp` is called again to
+        // flip
+        // this flag back to `true`.
+        isSetUp = true
+        return runnable
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV21.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV21.kt
new file mode 100644
index 0000000..a2b1c7c
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV21.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.os.Looper
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.UiThread
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.geometry.MutableBox
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+
+/**
+ * An implementation of [InProgressStrokesRenderHelper] that works on Android versions before
+ * [android.os.Build.VERSION_CODES.Q]. This implementation renders in-progress strokes via the
+ * [View] hierarchy using a [CanvasStrokeRenderer], where everything occurs on the UI thread.
+ * Support of pre-Q Android versions comes with the expense of rendering latency that is higher than
+ * it would be with [androidx.graphics.lowlatency.CanvasFrontBufferedRenderer].
+ */
+@OptIn(ExperimentalLatencyDataApi::class)
+@UiThread
+internal class CanvasInProgressStrokesRenderHelperV21(
+    private val mainView: ViewGroup,
+    private val callback: InProgressStrokesRenderHelper.Callback,
+    private val renderer: CanvasStrokeRenderer,
+) : InProgressStrokesRenderHelper {
+
+    // View hierarchy rendering does not retain its contents between frames, so all contents must be
+    // redrawn on every frame.
+    override val contentsPreservedBetweenDraws = false
+
+    override val supportsDebounce = false
+
+    override val supportsFlush = false
+
+    override var maskPath: Path? = null
+
+    private val maskPaint =
+        Paint().apply {
+            color = Color.TRANSPARENT
+            xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
+        }
+
+    /** Set during active drawing, unset otherwise. */
+    private var canvasForCurrentDraw: Canvas? = null
+
+    private val innerView =
+        object : View(mainView.context) {
+            override fun onDraw(canvas: Canvas) {
+                assertOnUiThread()
+                // Just in case save/restores get imbalanced among callbacks
+                val originalSaveCount = canvas.saveCount
+                canvasForCurrentDraw = canvas
+                try {
+                    callback.onDraw()
+                } finally {
+                    // NOMUTANTS -- Defensive programming.
+                    canvasForCurrentDraw = null
+                }
+
+                // Clear the client-defined masked area.
+                maskPath?.let { canvas.drawPath(it, maskPaint) }
+
+                callback.onDrawComplete()
+                callback.setCustomLatencyDataField(finishesDrawCallsSetter)
+                // TODO: b/316891464 - Use Choreographer to estimate the next frame time.
+                callback.handOffAllLatencyData()
+
+                check(canvas.saveCount == originalSaveCount) {
+                    "Unbalanced saves and restores. Expected save count of $originalSaveCount, got ${canvas.saveCount}."
+                }
+            }
+        }
+
+    /**
+     * Defined as a lambda instead of a member function or companion object function to ensure that
+     * no extra allocation takes place when passing this function object into the higher-level
+     * callback.
+     */
+    private val finishesDrawCallsSetter = { data: LatencyData, timeNanos: Long ->
+        data.hwuiInProgressStrokesRenderHelperData.finishesDrawCalls = timeNanos
+    }
+
+    private val viewListener =
+        object : View.OnAttachStateChangeListener {
+            override fun onViewAttachedToWindow(v: View) {
+                addInnerToMainView()
+            }
+
+            override fun onViewDetachedFromWindow(v: View) {
+                mainView.removeView(innerView)
+            }
+        }
+
+    init {
+        if (mainView.isAttachedToWindow) {
+            addInnerToMainView()
+        }
+        mainView.addOnAttachStateChangeListener(viewListener)
+    }
+
+    override fun assertOnRenderThread() = assertOnUiThread()
+
+    private fun assertOnUiThread() {
+        check(Looper.myLooper() == Looper.getMainLooper()) {
+            "Expected to be running on UI thread, but instead running on ${Thread.currentThread()}."
+        }
+    }
+
+    override fun requestDraw() {
+        assertOnUiThread()
+        // This leads to innerView.onDraw.
+        innerView.invalidate()
+    }
+
+    override fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox) = Unit
+
+    override fun drawInModifiedRegion(
+        inProgressStroke: InProgressStroke,
+        strokeToMainViewTransform: Matrix,
+    ) {
+        assertOnUiThread()
+        val canvas =
+            checkNotNull(canvasForCurrentDraw) { "Can only render during Callback.onDraw." }
+        renderer.draw(canvas, inProgressStroke, strokeToMainViewTransform)
+    }
+
+    override fun afterDrawInModifiedRegion() = Unit
+
+    override fun clear() {
+        // View hierarchy rendering does not retain its buffer contents between frames (all contents
+        // must be redrawn with every frame), so clearing takes place automatically by simply not
+        // rendering anything in the next innerView.onDraw.
+    }
+
+    override fun requestStrokeCohortHandoffToHwui(
+        handingOff: Map<InProgressStrokeId, FinishedStroke>
+    ) {
+        // The callback will ensure that the handoff data is drawn in HWUI in its next frame.
+        callback.onStrokeCohortHandoffToHwui(handingOff)
+        // Ensure that the next innerView.onDraw, when it calls callback.onDraw, will not result in
+        // any
+        // calls to drawInModifiedRegion - which will ensure that innerView has no content on the
+        // next
+        // HWUI frame.
+        innerView.invalidate()
+        // Because everything is synchronized to HWUI frames, there is no need to delay the next
+        // cohort.
+        callback.onStrokeCohortHandoffToHwuiComplete()
+    }
+
+    private fun addInnerToMainView() {
+        assertOnUiThread()
+        mainView.addView(
+            innerView,
+            ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT,
+            ),
+        )
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV29.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV29.kt
new file mode 100644
index 0000000..5756569
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV29.kt
@@ -0,0 +1,547 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.annotation.SuppressLint
+import android.content.pm.ActivityInfo
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorSpace
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.PixelFormat
+import android.graphics.PorterDuff
+import android.graphics.Rect
+import android.graphics.RenderNode
+import android.hardware.DataSpace
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.SurfaceView
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.RequiresApi
+import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
+import androidx.graphics.lowlatency.CanvasFrontBufferedRenderer
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.geometry.MutableBox
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * An implementation of [InProgressStrokesRenderHelper] based on [CanvasFrontBufferedRenderer],
+ * which allows for low-latency rendering.
+ *
+ * @param mainView The [View] within which the front buffer should be constructed.
+ * @param callback How to render the desired content within the front buffer.
+ * @param renderer Draws individual stroke objects using [Canvas].
+ * @param useOffScreenFrameBuffer A temporary flag to gate the offscreen frame buffer feature for
+ *   reducing rendering artifacts until [CanvasInProgressStrokesRenderHelperV33] is fully rolled
+ *   out.
+ * @param canvasFrontBufferedRendererWrapper Override the default only for testing.
+ * @param uiThreadHandler Override the default only for testing.
+ */
+@Suppress("ObsoleteSdkInt") // TODO(b/262911421): Should not need to suppress.
+@RequiresApi(Build.VERSION_CODES.Q)
+@OptIn(ExperimentalLatencyDataApi::class)
+internal class CanvasInProgressStrokesRenderHelperV29(
+    private val mainView: ViewGroup,
+    private val callback: InProgressStrokesRenderHelper.Callback,
+    private val renderer: CanvasStrokeRenderer,
+    private val useOffScreenFrameBuffer: Boolean,
+    private val canvasFrontBufferedRendererWrapper: CanvasFrontBufferedRendererWrapper =
+        CanvasFrontBufferedRendererWrapperImpl(),
+    frontBufferToHwuiHandoffFactory: (SurfaceView) -> FrontBufferToHwuiHandoff = { surfaceView ->
+        FrontBufferToHwuiHandoff.create(
+            mainView,
+            surfaceView,
+            callback::onStrokeCohortHandoffToHwui,
+            callback::onStrokeCohortHandoffToHwuiComplete,
+        )
+    },
+    private val uiThreadHandler: Handler = Handler(Looper.getMainLooper()),
+) : InProgressStrokesRenderHelper {
+
+    // The front buffer is updated each time rather than cleared and completely redrawn every time
+    // as
+    // a performance optimization.
+    override val contentsPreservedBetweenDraws = true
+
+    override val supportsDebounce = true
+
+    override val supportsFlush = true
+
+    override var maskPath: Path? = null
+
+    private val maskPaint =
+        Paint().apply {
+            color = Color.TRANSPARENT
+            blendMode = BlendMode.CLEAR
+        }
+
+    private val surfaceView =
+        SurfaceView(mainView.context).apply {
+            setZOrderOnTop(true)
+            holder.setFormat(PixelFormat.TRANSLUCENT)
+        }
+
+    private val viewListener =
+        object : View.OnAttachStateChangeListener {
+            @UiThread
+            override fun onViewAttachedToWindow(v: View) {
+                addAndInitSurfaceView()
+            }
+
+            @UiThread
+            override fun onViewDetachedFromWindow(v: View) {
+                frontBufferToHwuiHandoff.cleanup()
+                canvasFrontBufferedRendererWrapper.release(::recordRenderThreadIdentity)
+                mainView.removeView(surfaceView)
+            }
+        }
+
+    /** Valid only during active drawing (when `duringDraw` is `true`). */
+    private val onDrawState =
+        object {
+            var duringDraw = false
+            var frontBufferCanvas: Canvas? = null
+            /** Only valid from [prepareToDrawInModifiedRegion] to [afterDrawInModifiedRegion]. */
+            var offScreenCanvas: Canvas? = null
+        }
+        get() {
+            assertOnRenderThread()
+            return field
+        }
+
+    private val canvasFrontBufferedRendererCallback =
+        object : CanvasFrontBufferedRendererWrapper.Callback {
+
+            @WorkerThread
+            override fun onDrawFrontBufferedLayer(
+                canvas: Canvas,
+                bufferWidth: Int,
+                bufferHeight: Int
+            ) {
+                recordRenderThreadIdentity()
+
+                if (useOffScreenFrameBuffer) {
+                    ensureOffScreenFrameBuffer(bufferWidth, bufferHeight)
+                }
+
+                // Just in case save/restores get imbalanced among callbacks
+                val originalSaveCount = canvas.saveCount
+
+                onDrawState.frontBufferCanvas = canvas
+
+                onDrawState.duringDraw = true
+                callback.onDraw()
+                onDrawState.duringDraw = false
+
+                // NOMUTANTS -- Defensive programming to avoid bad state being used later.
+                run { onDrawState.frontBufferCanvas = null }
+
+                // Clear the client-defined masked area.
+                maskPath?.let { canvas.drawPath(it, maskPaint) }
+
+                callback.onDrawComplete()
+                check(canvas.saveCount == originalSaveCount) {
+                    "Unbalanced saves and restores. Expected save count of $originalSaveCount, got ${canvas.saveCount}."
+                }
+            }
+
+            @WorkerThread
+            @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, lambda = 0)
+            override fun onFrontBufferedLayerRenderComplete(
+                transactionSetDataSpace: (SurfaceControlCompat, Int) -> Unit,
+                frontBufferedLayerSurfaceControl: SurfaceControlCompat,
+            ) {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                    transactionSetDataSpace(
+                        frontBufferedLayerSurfaceControl,
+                        DataSpace.DATASPACE_DISPLAY_P3
+                    )
+                }
+                callback.setCustomLatencyDataField(finishesDrawCallsSetter)
+                callback.handOffAllLatencyData()
+            }
+        }
+
+    /**
+     * Defined as a lambda instead of a member function or companion object function to ensure that
+     * no extra allocation takes place when passing this function object into the higher-level
+     * callback.
+     */
+    private val finishesDrawCallsSetter = { data: LatencyData, timeNanos: Long ->
+        data.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = timeNanos
+    }
+
+    private val frontBufferToHwuiHandoff = frontBufferToHwuiHandoffFactory(surfaceView)
+
+    /** Saved to later ensure that certain operations are running on the appropriate thread. */
+    private lateinit var renderThread: Thread
+
+    private var offScreenFrameBuffer: RenderNode? = null
+    private val offScreenFrameBufferPaint =
+        Paint().apply {
+            // The SRC blend mode ensures that the modified region of the offscreen frame buffer
+            // completely
+            // replaces the matching region of the front buffer.
+            blendMode = BlendMode.SRC
+        }
+    private val scratchRect = Rect()
+
+    init {
+        if (mainView.isAttachedToWindow) {
+            addAndInitSurfaceView()
+        }
+        mainView.addOnAttachStateChangeListener(viewListener)
+    }
+
+    @UiThread
+    override fun requestDraw() {
+        canvasFrontBufferedRendererWrapper.renderFrontBufferedLayer()
+    }
+
+    @WorkerThread
+    override fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox) {
+        assertOnRenderThread()
+        check(onDrawState.duringDraw) { "Can only prepare to render during Callback.onDraw." }
+        val frontBufferCanvas = checkNotNull(onDrawState.frontBufferCanvas)
+        // Save the previous clip state. Restored in `afterDrawInModifiedRegion`.
+        frontBufferCanvas.save()
+
+        if (useOffScreenFrameBuffer) {
+            val offScreenCanvas = checkNotNull(offScreenFrameBuffer).beginRecording()
+            offScreenCanvas.save()
+            onDrawState.offScreenCanvas = offScreenCanvas
+        }
+
+        // Set the clip to only apply changes to the modified region.
+        // Clip uses integers, so round floats in a way that makes sure the entire updated region
+        // is captured. For the starting point (smallest values) round down, and for the ending
+        // point
+        // (largest values) round up. Pad the region a bit to avoid potential rounding errors
+        // leading to
+        // stray artifacts.
+        val clipRegionOutset = renderer.strokeModifiedRegionOutsetPx()
+
+        // Make sure to set the clip region for both the offscreen canvas and the front buffer
+        // canvas.
+        // The offscreen canvas is where the stroke draw operations are going first, so clipping
+        // ensures that the minimum number of draw operations are being performed. And when the off
+        // screen canvas is being drawn over to the front buffer canvas, the offscreen canvas only
+        // has
+        // content within the clip region, so setting the same clip region on the front buffer
+        // canvas
+        // ensures that only that region is copied over - both for performance to avoid copying an
+        // entire screen-sized buffer, but also for correctness to ensure that the retained contents
+        // of
+        // the front buffer outside of the modified region aren't cleared.
+        scratchRect.set(
+            /* left = */ floor(modifiedRegionInMainView.xMin).toInt() - clipRegionOutset,
+            /* top = */ floor(modifiedRegionInMainView.yMin).toInt() - clipRegionOutset,
+            /* right = */ ceil(modifiedRegionInMainView.xMax).toInt() + clipRegionOutset,
+            /* bottom = */ ceil(modifiedRegionInMainView.yMax).toInt() + clipRegionOutset,
+        )
+        frontBufferCanvas.clipRect(scratchRect)
+        // Using RenderNode.setClipRect instead of Canvas.clipRect for the offscreen frame buffer
+        // works
+        // better. With the latter, the clipping region would sometimes appear a little behind where
+        // it
+        // should be. If the RenderNode of the front buffer were available with
+        // CanvasFrontBufferedRenderer, then it would be preferred to set the clipping region on
+        // that
+        // instead of on frontBufferCanvas above. Note that setting the clipping region on the
+        // RenderNode for both the front buffer and offscreen frame buffer is the strategy used by
+        // the
+        // v33 implementation of this class.
+        if (useOffScreenFrameBuffer) {
+            checkNotNull(offScreenFrameBuffer).setClipRect(scratchRect)
+        }
+
+        // Clear the updated region of the offscreen frame buffer rather than the front buffer
+        // because
+        // the entire updated region will be copied from the former to the latter anyway. This way,
+        // the
+        // clear and draw operations will appear as one data-copying operation to the front buffer,
+        // rather than as two separate operations. As two separate operations, the time between the
+        // two
+        // can be visible due to scanline racing, which can cause parts of the background to peek
+        // through the content being rendered.
+        val canvasToClear =
+            if (useOffScreenFrameBuffer) {
+                checkNotNull(onDrawState.offScreenCanvas)
+            } else {
+                frontBufferCanvas
+            }
+        canvasToClear.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
+    }
+
+    @WorkerThread
+    override fun drawInModifiedRegion(
+        inProgressStroke: InProgressStroke,
+        strokeToMainViewTransform: Matrix,
+    ) {
+        assertOnRenderThread()
+        check(onDrawState.duringDraw) { "Can only render during Callback.onDraw." }
+
+        renderer.draw(
+            if (useOffScreenFrameBuffer) {
+                checkNotNull(onDrawState.offScreenCanvas)
+            } else {
+                checkNotNull(onDrawState.frontBufferCanvas)
+            },
+            inProgressStroke,
+            strokeToMainViewTransform,
+        )
+    }
+
+    @WorkerThread
+    override fun afterDrawInModifiedRegion() {
+        assertOnRenderThread()
+        check(onDrawState.duringDraw) { "Can only finalize rendering during Callback.onDraw." }
+        val frontBufferCanvas = checkNotNull(onDrawState.frontBufferCanvas)
+
+        if (useOffScreenFrameBuffer) {
+            val offScreenRenderNode = checkNotNull(offScreenFrameBuffer)
+
+            // Previously saved in `prepareToDrawInModifiedRegion`.
+            checkNotNull(onDrawState.offScreenCanvas).restore()
+
+            offScreenRenderNode.endRecording()
+            check(offScreenRenderNode.hasDisplayList())
+
+            // offScreenRenderNode is configured with BlendMode=SRC so that drawRenderNode replaces
+            // the
+            // contents of the front buffer with the contents of the offscreen frame buffer, within
+            // the
+            // clip bounds set in `prepareToDrawInModifiedRegion` above.
+            frontBufferCanvas.drawRenderNode(offScreenRenderNode)
+
+            offScreenRenderNode.setClipRect(null)
+            onDrawState.offScreenCanvas = null
+        }
+
+        // Previously saved in `prepareToDrawInModifiedRegion`.
+        frontBufferCanvas.restore()
+    }
+
+    @WorkerThread
+    override fun clear() {
+        assertOnRenderThread()
+        check(onDrawState.duringDraw) { "Can only clear during Callback.onDraw." }
+
+        checkNotNull(onDrawState.frontBufferCanvas)
+            .drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
+    }
+
+    @UiThread
+    override fun requestStrokeCohortHandoffToHwui(
+        handingOff: Map<InProgressStrokeId, FinishedStroke>
+    ) {
+        frontBufferToHwuiHandoff.requestCohortHandoff(handingOff)
+    }
+
+    @WorkerThread
+    override fun assertOnRenderThread() {
+        check(::renderThread.isInitialized) { "Don't yet know how to identify the render thread." }
+        check(Thread.currentThread() == renderThread) {
+            "Should be running on the render thread, but instead running on ${Thread.currentThread()}."
+        }
+    }
+
+    @WorkerThread
+    private fun ensureOffScreenFrameBuffer(width: Int, height: Int) {
+        assertOnRenderThread()
+        check(useOffScreenFrameBuffer)
+        val existingBuffer = offScreenFrameBuffer
+        if (
+            existingBuffer != null &&
+                existingBuffer.width == width &&
+                existingBuffer.height == height
+        ) {
+            // The existing buffer still works, use it.
+            return
+        }
+        offScreenFrameBuffer =
+            RenderNode(CanvasInProgressStrokesRenderHelperV29::class.java.simpleName + "-OffScreen")
+                .apply {
+                    setPosition(0, 0, width, height)
+                    setHasOverlappingRendering(true)
+                    // Use BlendMode=SRC so that the contents of the offscreen frame buffer replace
+                    // the
+                    // contents of the front buffer (restricted to the clip region).
+                    setUseCompositingLayer(
+                        /* forceToLayer= */ true,
+                        /* paint= */ offScreenFrameBufferPaint
+                    )
+                }
+    }
+
+    @UiThread
+    private fun addAndInitSurfaceView() {
+        mainView.addView(
+            surfaceView,
+            ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT,
+            ),
+        )
+        canvasFrontBufferedRendererWrapper.init(surfaceView, canvasFrontBufferedRendererCallback)
+        frontBufferToHwuiHandoff.setup()
+
+        // The Hardware Composer (HWC) does not render sRGB color space content correctly when
+        // compositing the front buffer layer, so force both the front buffered renderer and HWUI to
+        // work in the Display P3 color space in order to ensure that content looks the same when
+        // handed
+        // off from one to the other. This is also set on the front buffer layer itself from
+        // onFrontBufferedLayerRenderComplete.
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            canvasFrontBufferedRendererWrapper.setColorSpace(
+                checkNotNull(ColorSpace.getFromDataSpace(DataSpace.DATASPACE_DISPLAY_P3))
+            )
+            if (mainView.display?.isWideColorGamut == true) {
+                WindowFinder.findWindow(mainView)?.colorMode =
+                    ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
+            }
+        }
+    }
+
+    @WorkerThread
+    private fun recordRenderThreadIdentity() {
+        if (!::renderThread.isInitialized) {
+            renderThread = Thread.currentThread()
+        }
+        // Catch cases where the render thread changes since we recorded its identity.
+        assertOnRenderThread()
+    }
+
+    /**
+     * [CanvasFrontBufferedRenderer] is final, so use this for faking/mocking.
+     *
+     * @see CanvasFrontBufferedRenderer
+     */
+    internal interface CanvasFrontBufferedRendererWrapper {
+
+        /** @see CanvasFrontBufferedRenderer */
+        @UiThread fun init(surfaceView: SurfaceView, callback: Callback)
+
+        @UiThread fun setColorSpace(colorSpace: ColorSpace)
+
+        /** @see CanvasFrontBufferedRenderer.renderFrontBufferedLayer */
+        @UiThread fun renderFrontBufferedLayer()
+
+        /** @see CanvasFrontBufferedRenderer.release */
+        @UiThread fun release(onReleaseComplete: (() -> Unit)? = null)
+
+        /** @see CanvasFrontBufferedRenderer.Callback */
+        interface Callback {
+
+            /** @see CanvasFrontBufferedRenderer.Callback.onDrawFrontBufferedLayer */
+            @WorkerThread
+            fun onDrawFrontBufferedLayer(canvas: Canvas, bufferWidth: Int, bufferHeight: Int)
+
+            /** @see CanvasFrontBufferedRenderer.Callback.onFrontBufferedLayerRenderComplete */
+            @WorkerThread
+            fun onFrontBufferedLayerRenderComplete(
+                transactionSetDataSpace: (SurfaceControlCompat, Int) -> Unit,
+                frontBufferedLayerSurfaceControl: SurfaceControlCompat,
+            )
+        }
+    }
+
+    /**
+     * The real implementation based on [CanvasFrontBufferedRenderer], which is not intended to be
+     * unit testable.
+     */
+    private class CanvasFrontBufferedRendererWrapperImpl : CanvasFrontBufferedRendererWrapper {
+        private var delegate: CanvasFrontBufferedRenderer<Unit>? = null
+
+        @UiThread
+        override fun init(
+            surfaceView: SurfaceView,
+            callback: CanvasFrontBufferedRendererWrapper.Callback,
+        ) {
+            delegate =
+                CanvasFrontBufferedRenderer(
+                    surfaceView,
+                    object : CanvasFrontBufferedRenderer.Callback<Unit> {
+                        @WorkerThread
+                        override fun onDrawFrontBufferedLayer(
+                            canvas: Canvas,
+                            bufferWidth: Int,
+                            bufferHeight: Int,
+                            param: Unit,
+                        ) {
+                            callback.onDrawFrontBufferedLayer(canvas, bufferWidth, bufferHeight)
+                        }
+
+                        // NewApi suppress: SurfaceControlCompat.Transaction already handles
+                        // delegating to
+                        // version-specific implementations for setDataSpace, so it doesn't need a
+                        // compile-time RequiresApi check. We only execute setDataSpace on the
+                        // high enough API versions where it does what we want.
+                        @SuppressLint("NewApi")
+                        @WorkerThread
+                        override fun onFrontBufferedLayerRenderComplete(
+                            frontBufferedLayerSurfaceControl: SurfaceControlCompat,
+                            transaction: SurfaceControlCompat.Transaction,
+                        ) {
+                            callback.onFrontBufferedLayerRenderComplete(
+                                transaction::setDataSpace,
+                                frontBufferedLayerSurfaceControl,
+                            )
+                        }
+
+                        @WorkerThread
+                        override fun onDrawMultiBufferedLayer(
+                            canvas: Canvas,
+                            bufferWidth: Int,
+                            bufferHeight: Int,
+                            params: Collection<Unit>,
+                        ) {
+                            // Do nothing - our code never calls commit().
+                        }
+                    },
+                )
+        }
+
+        override fun setColorSpace(colorSpace: ColorSpace) {
+            delegate?.colorSpace = colorSpace
+        }
+
+        @UiThread
+        override fun renderFrontBufferedLayer() {
+            delegate?.renderFrontBufferedLayer(Unit)
+        }
+
+        @UiThread
+        override fun release(onReleaseComplete: (() -> Unit)?) {
+            delegate?.release(cancelPending = true, onReleaseComplete)
+            delegate = null
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33.kt
new file mode 100644
index 0000000..ad993fb
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/CanvasInProgressStrokesRenderHelperV33.kt
@@ -0,0 +1,1003 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.content.pm.ActivityInfo
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorSpace
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.PixelFormat
+import android.graphics.Rect
+import android.graphics.RenderNode
+import android.hardware.DataSpace
+import android.hardware.HardwareBuffer
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Looper
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.AnyThread
+import androidx.annotation.RequiresApi
+import androidx.annotation.UiThread
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.WorkerThread
+import androidx.graphics.CanvasBufferedRenderer
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.hardware.SyncFenceCompat
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.geometry.MutableBox
+import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
+import androidx.ink.strokes.InProgressStroke
+import java.util.concurrent.Executor
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * An implementation of [InProgressStrokesRenderHelper] based on [CanvasBufferedRenderer] and
+ * [SurfaceControlCompat], which allow for low-latency rendering. Compared to
+ * [CanvasInProgressStrokesRenderHelperV29], this implementation has stronger guarantees about
+ * avoiding flickers, and implements handoff of strokes to a HWUI-based client in a more efficient
+ * way that minimizes the time when input handling and rendering are frozen.
+ *
+ * @param mainView The [View] within which the front buffer should be constructed.
+ * @param callback How to render the desired content within the front buffer.
+ * @param renderer Draws individual stroke objects using [Canvas].
+ * @param uiThreadExecutor Replace the default for testing only.
+ */
+@Suppress("ObsoleteSdkInt") // TODO(b/262911421): Should not need to suppress.
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+@OptIn(ExperimentalLatencyDataApi::class)
+internal class CanvasInProgressStrokesRenderHelperV33(
+    private val mainView: ViewGroup,
+    private val callback: InProgressStrokesRenderHelper.Callback,
+    private val renderer: CanvasStrokeRenderer,
+    private val uiThreadExecutor: ScheduledExecutor =
+        Looper.getMainLooper().let { looper ->
+            ScheduledExecutorImpl(looper.thread, Handler(looper))
+        },
+    private val renderThreadExecutor: ScheduledExecutor =
+        HandlerThread(CanvasInProgressStrokesRenderHelperV33::class.java.simpleName + "_Render")
+            .let {
+                it.start()
+                ScheduledExecutorImpl(it, Handler(it.looper))
+            },
+) : InProgressStrokesRenderHelper {
+
+    override val contentsPreservedBetweenDraws = true
+
+    override val supportsDebounce = true
+
+    override val supportsFlush = true
+
+    override var maskPath: Path? = null
+
+    private val surfaceView =
+        SurfaceView(mainView.context).apply {
+            setZOrderOnTop(true)
+            holder.setFormat(PixelFormat.TRANSLUCENT)
+        }
+
+    private var currentViewport: Viewport? = null
+
+    private val viewListener =
+        object : View.OnAttachStateChangeListener {
+            @UiThread
+            override fun onViewAttachedToWindow(v: View) {
+                addAndInitSurfaceView()
+            }
+
+            @UiThread
+            override fun onViewDetachedFromWindow(v: View) {
+                mainView.removeView(surfaceView)
+            }
+        }
+
+    private val surfaceListener =
+        object : SurfaceHolder.Callback2 {
+            override fun surfaceCreated(holder: SurfaceHolder) = Unit
+
+            override fun surfaceChanged(
+                holder: SurfaceHolder,
+                format: Int,
+                width: Int,
+                height: Int
+            ) {
+                if (width == 0 || height == 0) {
+                    onViewHiddenOrNoBounds()
+                } else {
+                    onViewVisibleWithBounds(width, height)
+                }
+            }
+
+            override fun surfaceDestroyed(holder: SurfaceHolder) {
+                onViewHiddenOrNoBounds()
+            }
+
+            override fun surfaceRedrawNeeded(holder: SurfaceHolder) = Unit
+        }
+
+    private val requestDrawRenderThreadRunnable = Runnable {
+        assertOnRenderThread()
+        currentViewport?.handleDraw()
+    }
+
+    /**
+     * Defined as a lambda instead of a member function or companion object function to ensure that
+     * no extra allocation takes place when passing this function object into the higher-level
+     * callback.
+     */
+    private val finishesDrawCallsSetter = { data: LatencyData, timeNanos: Long ->
+        data.canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = timeNanos
+    }
+
+    private val maskPaint =
+        Paint().apply {
+            color = Color.TRANSPARENT
+            blendMode = BlendMode.CLEAR
+        }
+
+    private val offScreenFrameBufferPaint =
+        Paint().apply {
+            // The SRC blend mode ensures that the modified region of the offscreen frame buffer
+            // completely
+            // replaces the matching region of the front buffer.
+            blendMode = BlendMode.SRC
+        }
+
+    private var colorSpaceDataSpaceOverride: Pair<ColorSpace, Int>? = null
+
+    init {
+        if (mainView.isAttachedToWindow) {
+            addAndInitSurfaceView()
+        }
+        mainView.addOnAttachStateChangeListener(viewListener)
+    }
+
+    @WorkerThread
+    override fun assertOnRenderThread() {
+        check(renderThreadExecutor.onThread()) {
+            "Should be running on render thread, but actually running on ${Thread.currentThread()}."
+        }
+    }
+
+    @UiThread
+    override fun requestDraw() {
+        renderThreadExecutor.execute(requestDrawRenderThreadRunnable)
+    }
+
+    @WorkerThread
+    override fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox) {
+        currentViewport?.prepareToDrawInModifiedRegion(modifiedRegionInMainView)
+    }
+
+    @WorkerThread
+    override fun drawInModifiedRegion(
+        inProgressStroke: InProgressStroke,
+        strokeToMainViewTransform: Matrix,
+    ) {
+        currentViewport?.drawInModifiedRegion(inProgressStroke, strokeToMainViewTransform)
+    }
+
+    @WorkerThread
+    override fun afterDrawInModifiedRegion() {
+        currentViewport?.afterDrawInModifiedRegion()
+    }
+
+    @UiThread
+    override fun requestStrokeCohortHandoffToHwui(
+        handingOff: Map<InProgressStrokeId, FinishedStroke>
+    ) {
+        currentViewport?.requestHandoff(handingOff)
+    }
+
+    @WorkerThread
+    override fun clear() {
+        currentViewport?.clear()
+    }
+
+    @UiThread
+    private fun addAndInitSurfaceView() {
+        mainView.addView(
+            surfaceView,
+            ViewGroup.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT,
+            ),
+        )
+        surfaceView.holder.addCallback(surfaceListener)
+
+        // The Hardware Composer (HWC) does not render sRGB color space content correctly when
+        // compositing the front buffer layer, so force both the front buffered renderer and HWUI to
+        // work in the Display P3 color space in order to ensure that content looks the same when
+        // handed
+        // off from one to the other. This is also set on the front buffer layer itself from
+        // onFrontBufferedLayerRenderComplete.
+        if (
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+                mainView.display?.isWideColorGamut == true
+        ) {
+            colorSpaceDataSpaceOverride =
+                Pair(ColorSpace.get(ColorSpace.Named.DISPLAY_P3), DataSpace.DATASPACE_DISPLAY_P3)
+            WindowFinder.findWindow(mainView)?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
+        }
+    }
+
+    @UiThread
+    private fun onViewVisibleWithBounds(width: Int, height: Int) {
+        assertOnUiThread()
+        val oldViewport = currentViewport
+        val newBounds = getNewBounds(oldViewport?.bounds, width, height)
+        if (newBounds == oldViewport?.bounds) {
+            return
+        }
+        oldViewport?.discard()
+        val newViewport = Viewport(newBounds)
+        currentViewport = newViewport
+        newViewport.init()
+    }
+
+    @UiThread
+    private fun onViewHiddenOrNoBounds() {
+        assertOnUiThread()
+        currentViewport?.discard()
+        currentViewport = null
+    }
+
+    private fun getNewBounds(oldBounds: Bounds?, newWidth: Int, newHeight: Int): Bounds {
+        assertOnUiThread()
+        val transformHint = checkNotNull(mainView.rootSurfaceControl).bufferTransformHint
+        if (
+            oldBounds != null &&
+                oldBounds.mainViewWidth == newWidth &&
+                oldBounds.mainViewHeight == newHeight &&
+                oldBounds.mainViewTransformHint == transformHint
+        ) {
+            return oldBounds
+        }
+        return Bounds(newWidth, newHeight, transformHint)
+    }
+
+    private fun assertOnUiThread() {
+        check(Looper.myLooper() == Looper.getMainLooper()) {
+            "Should be running on the UI thread, but instead running on ${Thread.currentThread()}."
+        }
+    }
+
+    private inner class Viewport(val bounds: Bounds) {
+
+        /**
+         * When a [Viewport] is no longer valid (e.g. when the [Bounds] change), this will be set to
+         * `true`, so that any in-flight callbacks don't execute on this now-invalid object.
+         */
+        private val discarded = AtomicBoolean(false)
+
+        /** Enforces that the current [BuffersState] is only modified from the UI thread. */
+        private val buffersState =
+            object {
+                private val stateInternal = AtomicReference<BuffersState?>()
+
+                @UiThread
+                fun checkAndSet(expectedValue: BuffersState?, newValue: BuffersState?) {
+                    assertOnUiThread()
+                    check(stateInternal.compareAndSet(expectedValue, newValue)) {
+                        "buffersState: expected $expectedValue, but current value is ${stateInternal.get()}"
+                    }
+                }
+
+                @UiThread
+                fun getAndSet(newValue: BuffersState?): BuffersState? {
+                    assertOnUiThread()
+                    return stateInternal.getAndSet(newValue)
+                }
+
+                @AnyThread fun get(): BuffersState? = stateInternal.get()
+            }
+
+        /**
+         * The next value to pass to [SurfaceControlCompat.Transaction.setLayer]. This increases
+         * with each handoff to ensure that the clear inactive buffer, ready to be activated and
+         * drawn on, is always showing above the active layer with content, so that later strokes
+         * are always drawn over earlier strokes. A positive number means drawing above the parent
+         * layer ([SurfaceView]), which is why we start with a value of 1.
+         *
+         * While this could theoretically overflow, it is incremented once per handoff. A handoff
+         * must contain at least one stroke, so it would take at least 2^31 strokes to cause this to
+         * overflow - well above our target number for an entire document of 10k. Because handoffs
+         * must have at least 500 milliseconds between them, 2^31 handoffs spaced 500 milliseconds
+         * apart would take about 34 years. And if an overflow did occur, the outcome is that for
+         * the brief moment when both front buffer layers are on screen simultaneously with content,
+         * the more recent layer (with more recent strokes) will appear below the older layer (with
+         * older strokes).
+         */
+        private val nextBufferLayer = AtomicInteger(1)
+
+        private val renderThreadState =
+            object {
+                var drawingTo: BufferData? = null
+                var drawsPending = 0
+                var frontBufferCanvas: Canvas? = null
+                val scratchRect = Rect()
+                /**
+                 * Only valid from [prepareToDrawInModifiedRegion] to [afterDrawInModifiedRegion].
+                 */
+                var offScreenCanvas: Canvas? = null
+            }
+
+        private val frontBufferIncrementalRenderCallback =
+            { renderResult: CanvasBufferedRenderer.RenderResult ->
+                assertOnRenderThread()
+                if (!discarded.get()) {
+                    onFrontBufferIncrementalRenderResult(renderResult.hardwareBuffer)
+                }
+            }
+
+        private val inactiveBufferIsHiddenCallback = Runnable {
+            assertOnUiThread()
+            if (discarded.get()) return@Runnable
+            onInactiveBufferHidden()
+        }
+
+        @UiThread
+        fun init() {
+            assertOnUiThread()
+            if (discarded.get()) return
+            val bufferData1 = createBufferData(bufferNumber = 1)
+            bufferData1.renderer
+                .obtainRenderRequest()
+                .preserveContents(true)
+                .apply {
+                    bounds.bufferTransformInverse?.let { setBufferTransform(it) }
+                    colorSpaceDataSpaceOverride?.let { setColorSpace(it.first) }
+                }
+                .drawAsync(uiThreadExecutor) { renderResult1 ->
+                    if (!discarded.get()) {
+                        check(renderResult1.status == CanvasBufferedRenderer.RenderResult.SUCCESS)
+                        val buffer1 = renderResult1.hardwareBuffer
+                        val initialRenderFence1 = renderResult1.fence
+                        val bufferData2 = createBufferData(bufferNumber = 2)
+                        bufferData2.renderer
+                            .obtainRenderRequest()
+                            .preserveContents(true)
+                            .apply {
+                                bounds.bufferTransformInverse?.let { setBufferTransform(it) }
+                                colorSpaceDataSpaceOverride?.let { setColorSpace(it.first) }
+                            }
+                            .drawAsync(uiThreadExecutor) { renderResult2 ->
+                                if (!discarded.get()) {
+                                    check(
+                                        renderResult2.status ==
+                                            CanvasBufferedRenderer.RenderResult.SUCCESS
+                                    )
+                                    val buffer2 = renderResult2.hardwareBuffer
+                                    val initialRenderFence2 = renderResult2.fence
+                                    val newState =
+                                        BuffersState(
+                                            active = bufferData1,
+                                            inactive = bufferData2,
+                                            inactiveIsReady = true,
+                                        )
+                                    buffersState.checkAndSet(expectedValue = null, newState)
+                                    SurfaceControlCompat.Transaction()
+                                        .setAndShow(newState.active, buffer1, initialRenderFence1)
+                                        .setAndShow(newState.inactive, buffer2, initialRenderFence2)
+                                        .commit()
+                                    mainView.invalidate()
+                                }
+                            }
+                    }
+                }
+        }
+
+        fun createBufferData(bufferNumber: Int): BufferData {
+            val renderer = createRenderer()
+            val debugName = createDebugName(bufferNumber)
+            // The actual draw instructions go into the offscreen RenderNode, while the front buffer
+            // RenderNode simply copies the contents of the content RenderNode but with the
+            // appropriate
+            // clip rectangle applied to ensure that the copy is only of the modified part of the
+            // buffer.
+            val offScreenRenderNode =
+                createRenderNode("$debugName-OffScreen").apply {
+                    setHasOverlappingRendering(true)
+                    // Use BlendMode=SRC so that the contents of the offscreen frame buffer replace
+                    // the
+                    // contents of the front buffer (restricted to the clip region).
+                    setUseCompositingLayer(
+                        /* forceToLayer= */ true,
+                        /* paint= */ offScreenFrameBufferPaint
+                    )
+                }
+            val frontBufferRenderNode = createRenderNode("$debugName-Front")
+            renderer.setContentRoot(frontBufferRenderNode)
+            return BufferData(
+                SurfaceControlCompat.Builder().setName(debugName).setParent(surfaceView).build(),
+                renderer,
+                frontBufferRenderNode,
+                offScreenRenderNode,
+            )
+        }
+
+        private fun createDebugName(bufferNumber: Int) =
+            "$bufferNumber-${CanvasInProgressStrokesRenderHelperV33::class.java.simpleName}"
+
+        private fun createRenderNode(name: String): RenderNode =
+            RenderNode(name).apply {
+                // Make RenderNode coordinates the same as the View coordinates.
+                setPosition(0, 0, bounds.mainViewWidth, bounds.mainViewHeight)
+            }
+
+        private fun createRenderer(): CanvasBufferedRenderer {
+            val usageFlags =
+                if (
+                    HardwareBuffer.isSupported(
+                        1,
+                        1,
+                        HardwareBuffer.RGBA_8888,
+                        1,
+                        DESIRED_USAGE_FLAGS
+                    )
+                ) {
+                    DESIRED_USAGE_FLAGS
+                } else {
+                    BASE_USAGE_FLAGS
+                }
+            return CanvasBufferedRenderer.Builder(bounds.bufferWidth, bounds.bufferHeight)
+                .setMaxBuffers(1)
+                .setBufferFormat(HardwareBuffer.RGBA_8888)
+                .setUsageFlags(usageFlags)
+                .build()
+        }
+
+        private fun SurfaceControlCompat.Transaction.setAndShow(
+            bufferData: BufferData,
+            hardwareBuffer: HardwareBuffer,
+            initialRenderFence: SyncFenceCompat?,
+        ): SurfaceControlCompat.Transaction {
+            val sc = bufferData.surfaceControl
+            setVisibility(sc, true)
+            // Pass the initial render SyncFence here to ensure that the buffer doesn't appear on
+            // screen
+            // until it has been cleared, just in case it contains garbage data (apparently may
+            // happen on
+            // certain devices).
+            setBuffer(sc, hardwareBuffer, initialRenderFence)
+            setLayer(sc, nextBufferLayer.getAndIncrement())
+            setFrameRate(
+                sc,
+                1000F,
+                SurfaceControlCompat.FRAME_RATE_COMPATIBILITY_DEFAULT,
+                SurfaceControlCompat.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS,
+            )
+            setPosition(sc, 0F, 0F)
+
+            if (bounds.bufferTransform != null) {
+                setBufferTransform(sc, bounds.bufferTransform)
+            }
+            colorSpaceDataSpaceOverride?.let { setDataSpace(sc, it.second) }
+
+            return this
+        }
+
+        @WorkerThread
+        fun handleDraw() {
+            assertOnRenderThread()
+            val state = buffersState.get()
+            if (state == null || renderThreadState.drawingTo != null) {
+                renderThreadState.drawsPending++
+                return
+            }
+            val activeBuffer = state.active
+            renderThreadState.drawingTo = activeBuffer
+            val frontBufferRenderNode = activeBuffer.frontBufferRenderNode
+            val frontBufferCanvas = frontBufferRenderNode.beginRecording()
+
+            // Just in case save/restores get imbalanced among callbacks
+            val originalSaveCount = frontBufferCanvas.saveCount
+
+            renderThreadState.frontBufferCanvas = frontBufferCanvas
+            callback.onDraw()
+            renderThreadState.frontBufferCanvas = null
+
+            // Clear the client-defined masked area.
+            maskPath?.let { frontBufferCanvas.drawPath(it, maskPaint) }
+
+            callback.onDrawComplete()
+            check(frontBufferCanvas.saveCount == originalSaveCount) {
+                "Unbalanced saves and restores. Expected save count of $originalSaveCount, got ${frontBufferCanvas.saveCount}."
+            }
+
+            frontBufferRenderNode.endRecording()
+
+            activeBuffer.renderer
+                .obtainRenderRequest()
+                .preserveContents(true)
+                .apply {
+                    bounds.bufferTransformInverse?.let { setBufferTransform(it) }
+                    colorSpaceDataSpaceOverride?.let { setColorSpace(it.first) }
+                }
+                .drawAsync(renderThreadExecutor, frontBufferIncrementalRenderCallback)
+        }
+
+        @WorkerThread
+        private fun onFrontBufferIncrementalRenderResult(hardwareBuffer: HardwareBuffer) {
+            assertOnRenderThread()
+            val state = buffersState.get() ?: return
+            val activeBuffer = state.active
+            // A handoff may have occurred while this front buffer render was still in progress.
+            // Only
+            // apply the new buffer to the active layer if it was the layer that this draw was
+            // actually
+            // initiated for. Applying the inactive layer's buffer to the active layer will cause
+            // visual
+            // inconsistencies. And we're better off not applying this transaction to the inactive
+            // layer,
+            // as there may be a transaction in progress to hide the inactive buffer in sync with
+            // HWUI
+            // rendering, and this transaction might interfere with that one which would cause a
+            // flicker.
+            // It's more likely that the inactive layer is already off screen anyway, but don't risk
+            // the
+            // flicker.
+            if (renderThreadState.drawingTo == activeBuffer) {
+                val sc = activeBuffer.surfaceControl
+                SurfaceControlCompat.Transaction()
+                    // For an incremental render like this, don't wait on a fence to show the buffer
+                    // - get it
+                    // in place for the display to read from it as soon as possible, so the render
+                    // races the
+                    // scanline.
+                    .setBuffer(sc, hardwareBuffer, fence = null)
+                    .apply { bounds.bufferTransform?.let { setBufferTransform(sc, it) } }
+                    .commit()
+            }
+            callback.setCustomLatencyDataField(finishesDrawCallsSetter)
+            callback.handOffAllLatencyData()
+            renderThreadState.drawingTo = null
+            if (renderThreadState.drawsPending > 0) {
+                renderThreadState.drawsPending--
+                handleDraw()
+            }
+        }
+
+        @WorkerThread
+        fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox) {
+            val frontBufferRenderNode =
+                checkNotNull(renderThreadState.drawingTo).frontBufferRenderNode
+            // Can only prepare to render during Callback.onDraw.
+            val frontBufferCanvas = checkNotNull(renderThreadState.frontBufferCanvas)
+            // Save the previous clip state. Restored in `afterDrawInModifiedRegion`.
+            frontBufferCanvas.save()
+
+            val offScreenRenderNode = checkNotNull(renderThreadState.drawingTo).offScreenRenderNode
+            val offScreenCanvas = offScreenRenderNode.beginRecording()
+            offScreenCanvas.save()
+            renderThreadState.offScreenCanvas = offScreenCanvas
+
+            // Set the clip to only apply changes to the modified region.
+            // Clip uses integers, so round floats in a way that makes sure the entire updated
+            // region
+            // is captured. For the starting point (smallest values) round down, and for the ending
+            // point
+            // (largest values) round up. Pad the region a bit to avoid potential rounding errors
+            // leading
+            // to stray artifacts.
+            val clipRegionOutset = renderer.strokeModifiedRegionOutsetPx()
+            renderThreadState.scratchRect.set(
+                /* left = */ floor(modifiedRegionInMainView.xMin).toInt() - clipRegionOutset,
+                /* top = */ floor(modifiedRegionInMainView.yMin).toInt() - clipRegionOutset,
+                /* right = */ ceil(modifiedRegionInMainView.xMax).toInt() + clipRegionOutset,
+                /* bottom = */ ceil(modifiedRegionInMainView.yMax).toInt() + clipRegionOutset,
+            )
+            // Make sure to set the clip region for both the off screen canvas and the front buffer
+            // canvas. The off screen canvas is where the stroke draw operations are going first, so
+            // clipping ensures that the minimum number of draw operations are being performed. And
+            // when
+            // the off screen canvas is being drawn over to the front buffer canvas, the off screen
+            // canvas
+            // only has content within the clip region, so setting the same clip region on the front
+            // buffer canvas ensures that only that region is copied over - both for performance to
+            // avoid
+            // copying an entire screen-sized buffer, but also for correctness to ensure that the
+            // retained
+            // contents of the front buffer outside of the modified region aren't cleared.
+            // Use RenderNode.setClipRect instead of Canvas.clipRect to avoid visual artifacts that
+            // were
+            // appearing with the latter.
+            frontBufferRenderNode.setClipRect(renderThreadState.scratchRect)
+            offScreenRenderNode.setClipRect(renderThreadState.scratchRect)
+
+            // Clear the updated region of the offscreen frame buffer rather than the front buffer
+            // because
+            // the entire updated region will be copied from the former to the latter anyway. This
+            // way,
+            // the clear and draw operations will appear as one data-copying operation to the front
+            // buffer, rather than as two separate operations. As two separate operations, the time
+            // between the two can be visible due to scanline racing, which can cause parts of the
+            // background to peek through the content being rendered.
+            offScreenCanvas.drawColor(Color.TRANSPARENT, BlendMode.CLEAR)
+        }
+
+        @WorkerThread
+        fun drawInModifiedRegion(
+            inProgressStroke: InProgressStroke,
+            strokeToMainViewTransform: Matrix,
+        ) {
+            renderer.draw(
+                checkNotNull(renderThreadState.offScreenCanvas),
+                inProgressStroke,
+                strokeToMainViewTransform,
+            )
+        }
+
+        @WorkerThread
+        fun afterDrawInModifiedRegion() {
+            // Can only finalize rendering during Callback.onDraw.
+            val frontBufferRenderNode =
+                checkNotNull(renderThreadState.drawingTo).frontBufferRenderNode
+            val frontBufferCanvas = checkNotNull(renderThreadState.frontBufferCanvas)
+            val offScreenRenderNode = checkNotNull(renderThreadState.drawingTo).offScreenRenderNode
+
+            // Previously saved in `prepareToDrawInModifiedRegion`.
+            checkNotNull(renderThreadState.offScreenCanvas).restore()
+
+            offScreenRenderNode.endRecording()
+            check(offScreenRenderNode.hasDisplayList())
+
+            // offScreenRenderNode is configured with BlendMode=SRC so that drawRenderNode replaces
+            // the
+            // contents of the front buffer with the contents of the off screen frame buffer, within
+            // the
+            // clip bounds set in `prepareToDrawInModifiedRegion` above.
+            frontBufferCanvas.drawRenderNode(offScreenRenderNode)
+
+            offScreenRenderNode.setClipRect(null)
+            frontBufferRenderNode.setClipRect(null)
+            renderThreadState.offScreenCanvas = null
+
+            // Previously saved in `prepareToDrawInModifiedRegion`.
+            frontBufferCanvas.restore()
+        }
+
+        @UiThread
+        fun requestHandoff(strokeCohort: Map<InProgressStrokeId, FinishedStroke>) {
+            assertOnUiThread()
+            val state = buffersState.get() ?: return
+            check(state.inactiveIsReady) {
+                "Handoffs should be paused until the inactive buffer is ready again."
+            }
+
+            val rootSurfaceControl = checkNotNull(mainView.rootSurfaceControl)
+            // Pause handoffs until the current buffer is fully off screen and cleared, because if
+            // the
+            // contents of the next buffer also need to be handed off then there will be no buffer
+            // to
+            // draw new inputs.
+            callback.setPauseStrokeCohortHandoffs(true)
+
+            val sc = state.active.surfaceControl
+            SurfaceControlCompat.Transaction()
+                .setVisibility(sc, false)
+                .setBuffer(sc, null)
+                .commitTransactionOnDraw(rootSurfaceControl)
+            mainView.invalidate()
+            callback.onStrokeCohortHandoffToHwui(strokeCohort)
+            val newState =
+                BuffersState(
+                    active = state.inactive,
+                    inactive = state.active,
+                    inactiveIsReady = false
+                )
+            buffersState.checkAndSet(state, newState)
+            // Allow input to be resumed right away, because there is another buffer that is visible
+            // to
+            // be able to show new inputs. This will process an already-queued clear action which
+            // will
+            // lead to deactivateCurrentBuffer below.
+            callback.onStrokeCohortHandoffToHwuiComplete()
+        }
+
+        @WorkerThread
+        fun clear() {
+            // The buffer is in the process of being moved off screen, but wait for that to finish
+            // to
+            // actually clear the buffer. We don't have to wait for that in order to start rendering
+            // new
+            // inputs to the other buffer. By swapping the inactive (clear) buffer in for the active
+            // buffer, we end up with a clear active buffer sooner.
+            deactivateCurrentBuffer()
+        }
+
+        /**
+         * Swap the active and inactive buffers. The formerly active buffer is still transitioning
+         * off screen
+         */
+        @WorkerThread
+        private fun deactivateCurrentBuffer() {
+            assertOnRenderThread()
+            // This delay time is practically guaranteed to be long enough for the previously active
+            // buffer to be hidden, so that it can be safely cleared and moved back on screen. This
+            // should
+            // only take about 1-5 vsync periods to get through the HWUI pipeline, which with a 60Hz
+            // display refresh rate is about 16-80ms. But with the approach of this RenderHelper
+            // using two
+            // front buffers, there is limited benefit to making this time as tight as possible,
+            // since it
+            // doesn't delay the next inputs from being drawn, it just prevents another handoff from
+            // occurring until this timer fires to consider the current handoff to be completed. If
+            // Android offered a callback/fence to indicate when a particular transaction has been
+            // presented to the display, then this delay could be based on that for tighter timing.
+            uiThreadExecutor.executeDelayed(
+                inactiveBufferIsHiddenCallback,
+                500,
+                TimeUnit.MILLISECONDS
+            )
+            // This will lead to onInactiveBufferHidden below.
+        }
+
+        @UiThread
+        fun onInactiveBufferHidden() {
+            val state = buffersState.get() ?: return
+            check(!state.inactiveIsReady)
+            // Clear the inactive layer now that it's safely off screen. Do this manually rather
+            // than with
+            // a simple render request with preserveContents(false), as the lack of a recorded
+            // drawing
+            // operation prevents the clear from completing fully.
+            val toClear = state.inactive
+            toClear.frontBufferRenderNode
+                .beginRecording()
+                .drawColor(Color.TRANSPARENT, BlendMode.CLEAR)
+            toClear.frontBufferRenderNode.endRecording()
+            toClear.renderer
+                .obtainRenderRequest()
+                .preserveContents(true) // Clearing manually above.
+                .apply {
+                    bounds.bufferTransformInverse?.let { setBufferTransform(it) }
+                    colorSpaceDataSpaceOverride?.let { setColorSpace(it.first) }
+                }
+                .drawAsync(uiThreadExecutor) { clearRenderResult ->
+                    if (discarded.get()) return@drawAsync
+                    val oldState = buffersState.get() ?: return@drawAsync
+                    check(oldState == state)
+                    val newInactiveBuffer = clearRenderResult.hardwareBuffer
+                    val clearRenderFence = clearRenderResult.fence
+                    clearRenderFence?.awaitForever()
+                    val sc = oldState.inactive.surfaceControl
+                    // Passing clearRenderFence to setBuffer in this transaction makes sure the
+                    // inactive
+                    // buffer can't show again until the clear operation has fully completed. If it
+                    // shows too
+                    // soon when it still has content, there would be a partial or complete
+                    // double-draw
+                    // flicker.
+                    SurfaceControlCompat.Transaction()
+                        .setVisibility(sc, true)
+                        .setBuffer(sc, newInactiveBuffer, clearRenderFence)
+                        .setLayer(sc, nextBufferLayer.getAndIncrement())
+                        .apply { bounds.bufferTransform?.let { setBufferTransform(sc, it) } }
+                        .commit()
+                    mainView.invalidate()
+                    val newState =
+                        BuffersState(
+                            active = oldState.active,
+                            inactive = oldState.inactive,
+                            inactiveIsReady = true,
+                        )
+                    buffersState.checkAndSet(oldState, newState)
+                    callback.setPauseStrokeCohortHandoffs(false)
+                }
+        }
+
+        @UiThread
+        fun discard() {
+            assertOnUiThread()
+            if (discarded.getAndSet(true)) return
+            val state = buffersState.getAndSet(null) ?: return
+            SurfaceControlCompat.Transaction()
+                .unsetAndHide(state.active)
+                .unsetAndHide(state.inactive)
+                .commit()
+            mainView.invalidate()
+            state.active.cleanup()
+            state.inactive.cleanup()
+        }
+
+        private fun SurfaceControlCompat.Transaction.unsetAndHide(
+            bufferData: BufferData
+        ): SurfaceControlCompat.Transaction {
+            val sc = bufferData.surfaceControl
+            setVisibility(sc, false)
+            setBuffer(sc, null)
+            setLayer(sc, 0)
+            clearFrameRate(sc)
+            setPosition(sc, 0F, 0F)
+            if (bounds.bufferTransform != null) {
+                setBufferTransform(sc, SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY)
+            }
+            return this
+        }
+    }
+
+    /**
+     * The dimensions of the rendering surface, along with a transform that must be used to render
+     * in the display's native coordinates for maximum performance. This serves as a sort of cache
+     * key for most of the state of the render helper, so the state can be preserved when the
+     * [Bounds] continue to be the same, and discarded when the [Bounds] change.
+     */
+    @VisibleForTesting
+    internal data class Bounds(
+        /** The width of [mainView]. */
+        val mainViewWidth: Int,
+        /** The height of [mainView]. */
+        val mainViewHeight: Int,
+        /**
+         * The system-provided suggestion on how to pre-transform rendered content into native
+         * display coordinates so that the system doesn't need to perform the transformation later
+         * in a way that could hinder performance. Not all values are supported - if the hint is not
+         * supported, then both [bufferTransform] and [bufferTransformInverse] will be null, and the
+         * system will use the hardware compositor for transformation later, which is not as
+         * performant but still functions.
+         */
+        val mainViewTransformHint: Int,
+    ) {
+        /**
+         * The width of the buffer used for rendering. This may be either [mainViewWidth] or
+         * [mainViewHeight] depending on whether [mainViewTransformHint] suggests rotating the
+         * buffer relative to the view or not.
+         */
+        val bufferWidth: Int
+        /**
+         * The height of the buffer used for rendering. This may be either [mainViewHeight] or
+         * [mainViewHeight] depending on whether [mainViewTransformHint] suggests rotating the
+         * buffer relative to the view or not.
+         */
+        val bufferHeight: Int
+        /**
+         * Equal to [mainViewTransformHint] if it is a type of transform that is handled by this
+         * class, or null otherwise.
+         */
+        val bufferTransform: Int?
+        /**
+         * The inverse of [bufferTransform], or null if and only if [bufferTransform] is also null.
+         */
+        val bufferTransformInverse: Int?
+
+        init {
+            // transformInverse will be null if the transform hint is not one that we know how to
+            // optimize, and therefore no transforms should be applied to the buffer and surface.
+            when (mainViewTransformHint) {
+                SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90,
+                SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270 -> {
+                    bufferTransform = mainViewTransformHint
+                    bufferTransformInverse =
+                        if (
+                            mainViewTransformHint == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90
+                        ) {
+                            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270
+                        } else {
+                            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90
+                        }
+                    // Flip the width and height of the buffer compared to the view.
+                    bufferWidth = mainViewHeight
+                    bufferHeight = mainViewWidth
+                }
+                else -> {
+                    when (mainViewTransformHint) {
+                        SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
+                        SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 -> {
+                            bufferTransform = mainViewTransformHint
+                            bufferTransformInverse = mainViewTransformHint
+                        }
+                        else -> {
+                            bufferTransform = null
+                            bufferTransformInverse = null
+                        }
+                    }
+                    bufferWidth = mainViewWidth
+                    bufferHeight = mainViewHeight
+                }
+            }
+            require((bufferTransform == null) == (bufferTransformInverse == null))
+        }
+    }
+
+    private class BufferData(
+        val surfaceControl: SurfaceControlCompat,
+        val renderer: CanvasBufferedRenderer,
+        val frontBufferRenderNode: RenderNode,
+        val offScreenRenderNode: RenderNode,
+    ) {
+        fun cleanup() {
+            renderer.close()
+            frontBufferRenderNode.discardDisplayList()
+            offScreenRenderNode.discardDisplayList()
+            surfaceControl.release()
+        }
+    }
+
+    /** Current state of buffers for a [Viewport]. Replaced atomically during handoff. */
+    private class BuffersState(
+        /** The buffer that draw calls will go to. */
+        val active: BufferData,
+        /** The buffer that will take over as [active] when a handoff is requested. */
+        val inactive: BufferData,
+        /**
+         * Whether [inactive] is ready to become [active]. If this is `false`, then handoff should
+         * not currently be allowed, and must wait until [inactive] is cleared and is on its way to
+         * being shown again. In the meantime, draws must continue going to [active].
+         */
+        val inactiveIsReady: Boolean,
+    ) {
+        init {
+            check(active != inactive)
+        }
+    }
+
+    /**
+     * Like [java.util.concurrent.ScheduledExecutorService], but a reduced interface that is easier
+     * to implement and fake.
+     */
+    interface ScheduledExecutor : Executor {
+        fun onThread(): Boolean
+
+        fun executeDelayed(command: Runnable, delayTime: Long, delayTimeUnit: TimeUnit)
+    }
+
+    private class ScheduledExecutorImpl(private val thread: Thread, private val handler: Handler) :
+        ScheduledExecutor {
+
+        override fun onThread() = Thread.currentThread() == thread
+
+        override fun execute(command: Runnable) {
+            check(thread.isAlive)
+            if (!handler.post(command)) {
+                throw RejectedExecutionException("$handler is shutting down")
+            }
+        }
+
+        override fun executeDelayed(command: Runnable, delayTime: Long, delayTimeUnit: TimeUnit) {
+            check(thread.isAlive)
+            if (!handler.postDelayed(command, delayTimeUnit.toMillis(delayTime))) {
+                throw RejectedExecutionException("$handler is shutting down")
+            }
+        }
+    }
+
+    companion object {
+
+        private const val BASE_USAGE_FLAGS =
+            HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
+                HardwareBuffer.USAGE_GPU_COLOR_OUTPUT or
+                HardwareBuffer.USAGE_COMPOSER_OVERLAY
+        private const val DESIRED_USAGE_FLAGS =
+            HardwareBuffer.USAGE_FRONT_BUFFER or BASE_USAGE_FLAGS
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FinishedStroke.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FinishedStroke.kt
new file mode 100644
index 0000000..974cb19
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FinishedStroke.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import androidx.ink.strokes.Stroke
+
+/**
+ * Includes the stroke (either [LegacyStroke] or [Stroke] during the migration to the latter) along
+ * with a transform indicating where the stroke is on screen.
+ */
+internal class FinishedStroke(
+    val stroke: Stroke,
+    val strokeToViewTransform: Matrix,
+)
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoff.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoff.kt
new file mode 100644
index 0000000..73168dd
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoff.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.os.Build
+import android.view.SurfaceView
+import android.view.ViewGroup
+import androidx.annotation.RequiresApi
+import androidx.annotation.UiThread
+import androidx.ink.authoring.InProgressStrokeId
+
+/**
+ * Aids [CanvasInProgressStrokesRenderHelperV29] in handing off rendering from front buffer (low
+ * latency) rendering to HWUI ([android.view.View] hierarchy) rendering. Interoperates with
+ * [androidx.graphics.lowlatency.GLFrontBufferedRenderer] and
+ * [androidx.graphics.lowlatency.CanvasFrontBufferedRenderer].
+ */
+@RequiresApi(Build.VERSION_CODES.Q)
+internal interface FrontBufferToHwuiHandoff {
+
+    @UiThread fun setup()
+
+    @UiThread fun cleanup()
+
+    /** Call from [InProgressStrokesRenderHelper.requestStrokeCohortHandoffToHwui]. */
+    @UiThread fun requestCohortHandoff(handingOff: Map<InProgressStrokeId, FinishedStroke>)
+
+    companion object {
+
+        /**
+         * Create and return a [FrontBufferToHwuiHandoff] instance that is appropriate to the API
+         * level.
+         *
+         * @param mainView The [ViewGroup] that the [surfaceView] belongs to.
+         * @param surfaceView The [surfaceView] that holds the front buffer render layer.
+         * @param onCohortHandoff Calls
+         *   [InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwui].
+         * @param onCohortHandoffComplete Calls
+         *   [InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwuiComplete].
+         */
+        fun create(
+            mainView: ViewGroup,
+            surfaceView: SurfaceView,
+            @UiThread onCohortHandoff: (Map<InProgressStrokeId, FinishedStroke>) -> Unit,
+            @UiThread onCohortHandoffComplete: () -> Unit,
+        ): FrontBufferToHwuiHandoff {
+            // TODO: b/328087803 - Samsung API 34 devices do not seem to execute the buffer release
+            //   callback that V34 relies on. So use V29 for those devices instead.
+            return if (
+                Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+                    Build.MANUFACTURER != "samsung"
+            ) {
+                FrontBufferToHwuiHandoffV34(
+                    mainView,
+                    surfaceView,
+                    onCohortHandoff,
+                    onCohortHandoffComplete
+                )
+            } else {
+                FrontBufferToHwuiHandoffV29(
+                    mainView,
+                    surfaceView,
+                    onCohortHandoff,
+                    onCohortHandoffComplete
+                )
+            }
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV29.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV29.kt
new file mode 100644
index 0000000..54cb279
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV29.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.os.Build
+import android.view.SurfaceView
+import android.view.ViewGroup
+import androidx.annotation.RequiresApi
+import androidx.annotation.UiThread
+import androidx.ink.authoring.InProgressStrokeId
+
+/**
+ * A version of [FrontBufferToHwuiHandoff] that relies on temporarily translating the [SurfaceView]
+ * off screen while the front buffer is cleared, with a timer that almost always results in a
+ * flicker-free handoff from front buffer rendering to HWUI-based multi-buffer rendering.
+ */
+@RequiresApi(Build.VERSION_CODES.Q)
+internal class FrontBufferToHwuiHandoffV29(
+    private val mainView: ViewGroup,
+    private val surfaceView: SurfaceView,
+    /**
+     * Called to hand off a group of finished strokes from being rendered internally to being
+     * rendered by a higher level in HWUI.
+     *
+     * @see InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwui
+     */
+    @UiThread private val onCohortHandoff: (Map<InProgressStrokeId, FinishedStroke>) -> Unit,
+
+    /**
+     * Called after [onCohortHandoff] when it is safe for higher level code to start drawing again.
+     *
+     * @see InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwuiComplete
+     */
+    @UiThread private val onCohortHandoffComplete: () -> Unit,
+
+    /**
+     * A function that allows registering a callback to run after all user inputs and developer UI
+     * thread callbacks. This is overridable for unit tests, but in real code should always be
+     * [android.view.View.postOnAnimation].
+     */
+    private val postOnAnimation: (Runnable) -> Unit = mainView::postOnAnimation,
+) : FrontBufferToHwuiHandoff {
+
+    private var afterHandoffFrameCount = 0
+
+    override fun setup() = Unit
+
+    override fun cleanup() = Unit
+
+    @UiThread
+    override fun requestCohortHandoff(handingOff: Map<InProgressStrokeId, FinishedStroke>) {
+        onCohortHandoff(handingOff)
+        hideThenWaitThenShow()
+    }
+
+    @UiThread
+    private fun hideThenWaitThenShow() {
+        // Translate the SurfaceView off screen so that it is no longer visible, synchronized with
+        // the
+        // next HWUI draw. This is done as a translation rather than changing the visibility to
+        // avoid
+        // SurfaceView destroying the underlying surface.
+        surfaceView.translationX = mainView.resources.displayMetrics.widthPixels * 2F
+        surfaceView.translationY = mainView.resources.displayMetrics.heightPixels * 2F
+        mainView.invalidate()
+        // This translate won't take effect right away, so wait a bit before clearing the surface
+        // and
+        // bringing it back on screen.
+        afterHandoffFrameCount = 0
+        postOnAnimation(::onAnimation)
+    }
+
+    @UiThread
+    private fun onAnimation() {
+        afterHandoffFrameCount++
+        if (afterHandoffFrameCount < HANDOFF_COMPLETE_FRAME_COUNT) {
+            postOnAnimation(::onAnimation)
+        } else {
+            onHandoffComplete()
+        }
+    }
+
+    @UiThread
+    private fun onHandoffComplete() {
+        show()
+        onCohortHandoffComplete()
+    }
+
+    /**
+     * Move the front buffer layer back on screen with the next HWUI draw. The front buffer is being
+     * cleared simultaneously with this via [onCohortHandoffComplete], but clearing the front buffer
+     * is much faster than a HWUI operation so the front buffer will be clear by the time it is back
+     * on screen.
+     */
+    @UiThread
+    private fun show() {
+        surfaceView.translationX = 0F
+        surfaceView.translationY = 0F
+        mainView.invalidate()
+    }
+
+    private companion object {
+        /**
+         * The number of frames (as delimited by [postOnAnimation] callbacks) to wait to know that
+         * [surfaceView] has been translated off screen and it is safe to clear the front buffer
+         * layer. This is an imperfect heuristic - if it's too low then the front buffer will be
+         * cleared before it's fully off screen which will lead to a flicker, but if it's too high
+         * then the front buffer will stay off screen longer than it needed to so the beginning of
+         * the next stroke may be delayed in rendering.
+         */
+        const val HANDOFF_COMPLETE_FRAME_COUNT = 5
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV34.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV34.kt
new file mode 100644
index 0000000..434301a
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/FrontBufferToHwuiHandoffV34.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Color
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.SurfaceView
+import android.view.ViewGroup
+import androidx.annotation.Px
+import androidx.annotation.RequiresApi
+import androidx.annotation.UiThread
+import androidx.graphics.CanvasBufferedRenderer
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.ink.authoring.InProgressStrokeId
+import java.util.concurrent.Executor
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A version of [FrontBufferToHwuiHandoff] that relies on temporarily translating the [SurfaceView]
+ * off screen while the front buffer is cleared, with a "companion" [HardwareBuffer] whose callbacks
+ * are able to replace the timer used in [FrontBufferToHwuiHandoffV29] for a more reliable result
+ * (fewer flickers in stress test scenarios) with tighter timing (shorter handoff duration in ideal
+ * scenarios).
+ */
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+internal class FrontBufferToHwuiHandoffV34(
+    private val mainView: ViewGroup,
+    private val surfaceView: SurfaceView,
+    /**
+     * Called to hand off a group of finished strokes from being rendered internally to being
+     * rendered by a higher level in HWUI.
+     *
+     * @see InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwui
+     */
+    @UiThread private val onCohortHandoff: (Map<InProgressStrokeId, FinishedStroke>) -> Unit,
+
+    /**
+     * Called after [onCohortHandoff] when it is safe for higher level code to start drawing again.
+     *
+     * @see InProgressStrokesRenderHelper.Callback.onStrokeCohortHandoffToHwuiComplete
+     */
+    @UiThread private val onCohortHandoffComplete: () -> Unit,
+    private val uiThreadExecutor: Executor =
+        object : Executor {
+            private val handler = Handler(Looper.getMainLooper())
+
+            override fun execute(command: Runnable) {
+                if (!handler.post(command)) {
+                    throw RejectedExecutionException("$handler is shutting down")
+                }
+            }
+        },
+) : FrontBufferToHwuiHandoff {
+
+    private val isInitialized = AtomicBoolean()
+
+    /**
+     * This surface layer will be shown and hidden in sync with [surfaceView] being translated on
+     * and off screen. [SurfaceView] doesn't directly give us callbacks to indicate whether it is
+     * fully off screen, but a [SurfaceControlCompat] is able to via a buffer release callback after
+     * that buffer's contents are copied out of it for display.
+     */
+    private val companionSurfaceControl =
+        SurfaceControlCompat.Builder().setName(this.javaClass.simpleName + "_Companion").build()
+
+    /**
+     * In order to take advantage of the buffer release callback of [companionSurfaceControl], a
+     * buffer must be set on it to later be unset and released. For our purposes, this buffer
+     * doesn't need to display anything useful, we just care about it for its release callback, so
+     * create it as a 1x1 buffer to minimize the amount of graphics memory it occupies.
+     */
+    private val companionBufferRenderer =
+        CanvasBufferedRenderer.Builder(
+                width = COMPANION_BUFFER_SIZE_PX,
+                height = COMPANION_BUFFER_SIZE_PX,
+            )
+            .setMaxBuffers(1)
+            .setBufferFormat(HardwareBuffer.RGBA_8888)
+            .build()
+
+    override fun setup() {
+        isInitialized.set(true)
+        companionBufferRenderer.setContentRoot(
+            RenderNode(this.javaClass.simpleName + "_Companion").apply {
+                // The position within the buffer to draw. Since we're drawing a transparent pixel
+                // it
+                // doesn't really matter, but when debugging, it's useful to be able to change
+                // COMPANION_BUFFER_SIZE_PX to something bigger (like 100) and the color to
+                // something
+                // visible (like Color.RED).
+                setPosition(
+                    /* left = */ 0,
+                    /* top = */ 0,
+                    /* right = */ COMPANION_BUFFER_SIZE_PX,
+                    /* bottom = */ COMPANION_BUFFER_SIZE_PX,
+                )
+                // Apply some drawing instructions to the buffer to ensure that it's not released
+                // too
+                // early, which would affect our handoff timing and result in a flicker.
+                beginRecording().drawColor(Color.TRANSPARENT)
+                endRecording()
+            }
+        )
+        setUpCompanionBuffer()
+    }
+
+    @UiThread
+    override fun cleanup() {
+        isInitialized.set(false)
+        companionBufferRenderer.close()
+        val transaction =
+            SurfaceControlCompat.Transaction()
+                .reparent(companionSurfaceControl, null)
+                .setVisibility(companionSurfaceControl, false)
+                // If there is a HardwareBuffer currently in use, this will trigger the release
+                // callback
+                // which will close the buffer after release.
+                .setBuffer(companionSurfaceControl, null)
+        transaction.commit()
+        companionSurfaceControl.release()
+    }
+
+    @UiThread
+    override fun requestCohortHandoff(handingOff: Map<InProgressStrokeId, FinishedStroke>) {
+        if (!isInitialized.get()) {
+            return
+        }
+        val renderRequest = companionBufferRenderer.obtainRenderRequest().preserveContents(true)
+
+        // Use uiThreadExecutor to ensure the below code runs on the main thread. Otherwise, the
+        // three
+        // critical actions - the handoff callback, the translation, and the companion buffer being
+        // released - are not guaranteed to happen in the same HWUI frame, which would result in a
+        // flicker.
+        renderRequest.drawAsync(uiThreadExecutor) {
+            if (!isInitialized.get()) {
+                return@drawAsync
+            }
+            onCohortHandoff(handingOff)
+
+            // Translate the SurfaceView (and therefore the front buffer) off screen so that it is
+            // no
+            // longer visible, synchronized with the next HWUI draw. This is done as a translation
+            // rather
+            // than changing the visibility to avoid SurfaceView destroying the underlying surface,
+            // which
+            // results in a performance penalty to recreate.
+            surfaceView.translationX = mainView.resources.displayMetrics.widthPixels * 2F
+            surfaceView.translationY = mainView.resources.displayMetrics.heightPixels * 2F
+
+            val rootSurfaceControl = checkNotNull(mainView.rootSurfaceControl)
+            val transaction =
+                SurfaceControlCompat.Transaction()
+                    .reparent(companionSurfaceControl, rootSurfaceControl)
+                    .setLayer(companionSurfaceControl, Int.MAX_VALUE)
+                    .setVisibility(companionSurfaceControl, false)
+                    // setBuffer(null) leads to the buffer's release callback being executed, which
+                    // leads to
+                    // onHandoffComplete.
+                    .setBuffer(companionSurfaceControl, null)
+            // The companion buffer will be on screen at the same time as the SurfaceView.
+            transaction.commitTransactionOnDraw(rootSurfaceControl)
+
+            // Ensure that the translation and the show transaction will actually take effect.
+            mainView.invalidate()
+        }
+    }
+
+    @UiThread
+    private fun onHandoffComplete() {
+        show()
+        onCohortHandoffComplete()
+    }
+
+    /**
+     * Move the front buffer layer back on screen with the next HWUI draw. The front buffer is being
+     * cleared simultaneously with this via [onCohortHandoffComplete], but clearing the front buffer
+     * is much faster than a HWUI operation so the front buffer will be clear by the time it is back
+     * on screen.
+     */
+    @UiThread
+    private fun show() {
+        setUpCompanionBuffer()
+    }
+
+    /**
+     * The companion buffer's visibility is kept in sync with [surfaceView] being on screen. Most
+     * importantly, it is hidden at the exact same time as [surfaceView], and the companion buffer's
+     * release callback is triggered immediately afterwards. That release callback is when we know
+     * it is safe to clear the front buffer and show it again.
+     */
+    private fun setUpCompanionBuffer() {
+        val renderRequest = companionBufferRenderer.obtainRenderRequest().preserveContents(true)
+
+        // Use uiThreadExecutor to ensure the below code runs on the main thread. Unlike
+        // requestCohortHandoff above, there aren't dire consequences (flickers) if the two main
+        // actions
+        // here - the translation and the companion buffer being shown - aren't in the same HWUI
+        // frame,
+        // but it makes the rest of the code easier to reason about if they are.
+        renderRequest.drawAsync(uiThreadExecutor) { renderResult ->
+            if (renderResult.status != CanvasBufferedRenderer.RenderResult.SUCCESS) {
+                return@drawAsync
+            }
+            val buffer = renderResult.hardwareBuffer
+            // Should never be null on U+. If it is null, then the release callback on setBuffer
+            // will
+            // never be called. That is why this class is limited to U+ even though
+            // commitTransactionOnDraw is available on T+.
+            val renderFence = checkNotNull(renderResult.fence)
+
+            // Undo the translation to bring the SurfaceView (and therefore the front buffer) back
+            // on
+            // screen.
+            surfaceView.translationX = 0F
+            surfaceView.translationY = 0F
+
+            val rootSurfaceControl = checkNotNull(mainView.rootSurfaceControl)
+            val transaction =
+                SurfaceControlCompat.Transaction()
+                    .reparent(companionSurfaceControl, rootSurfaceControl)
+                    .setLayer(companionSurfaceControl, Int.MAX_VALUE)
+                    // The buffer must be visible, otherwise the below release fence will run too
+                    // early.
+                    .setVisibility(companionSurfaceControl, true)
+                    // This release callback will be run later, when setBuffer(null) is called.
+                    // TODO: b/328087803 - Samsung API 34 devices don't seem to execute this release
+                    // callback,
+                    //   so those devices use the V29 version of this class.
+                    .setBuffer(companionSurfaceControl, buffer, renderFence) { releaseFence ->
+                        releaseFence.awaitForever()
+                        releaseFence.close()
+                        if (!isInitialized.get()) {
+                            buffer.close()
+                            return@setBuffer
+                        }
+                        uiThreadExecutor.execute(::onHandoffComplete)
+                        companionBufferRenderer.releaseBuffer(buffer, releaseFence)
+                    }
+            // The companion buffer will be on screen at the same time as the SurfaceView.
+            transaction.commitTransactionOnDraw(rootSurfaceControl)
+
+            // Ensure that the translation and the show transaction will actually take effect.
+            mainView.invalidate()
+        }
+    }
+
+    companion object {
+        @Px const val COMPANION_BUFFER_SIZE_PX = 1
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokePool.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokePool.kt
new file mode 100644
index 0000000..a99c5cd
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokePool.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import androidx.ink.strokes.InProgressStroke
+
+internal interface InProgressStrokePool {
+    fun obtain(): InProgressStroke
+
+    fun recycle(inProgressStroke: InProgressStroke)
+
+    fun trimToSize(maxSize: Int)
+
+    companion object {
+        fun create(): InProgressStrokePool = InProgressStrokePoolImpl()
+    }
+}
+
+private class InProgressStrokePoolImpl : InProgressStrokePool {
+
+    private val pool = mutableListOf<InProgressStroke>()
+
+    override fun obtain(): InProgressStroke = pool.removeFirstOrNull() ?: InProgressStroke()
+
+    override fun recycle(inProgressStroke: InProgressStroke) {
+        // Will be started with the actual brush when this InProgressStroke is reused, but
+        // defensively
+        // clear its data for now. This does not deallocate the space for its data, so it's ready to
+        // be
+        // reused with minimal cost compared to allocating a new one.
+        inProgressStroke.clear()
+        pool.add(inProgressStroke)
+    }
+
+    override fun trimToSize(maxSize: Int) {
+        require(maxSize >= 0)
+        if (pool.size <= maxSize) return
+        pool.subList(maxSize, pool.size).clear()
+        check(pool.size == maxSize)
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesManager.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesManager.kt
new file mode 100644
index 0000000..77ad651
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesManager.kt
@@ -0,0 +1,1481 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix as AndroidMatrix
+import android.util.Log
+import android.view.MotionEvent
+import androidx.annotation.CheckResult
+import androidx.annotation.Size
+import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.authoring.latency.LatencyDataCallback
+import androidx.ink.authoring.latency.LatencyDataPool
+import androidx.ink.brush.Brush
+import androidx.ink.geometry.BoxAccumulator
+import androidx.ink.geometry.MutableBox
+import androidx.ink.strokes.ImmutableStrokeInputBatch
+import androidx.ink.strokes.InProgressStroke
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.StrokeInput
+import androidx.ink.strokes.StrokeInputBatch
+import androidx.test.espresso.idling.CountingIdlingResource
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Accepts [MotionEvent] inputs for in-progress strokes, processes them into meshes, and draws them
+ * to screen with as little latency as possible. This coordinates the majority of logic for the
+ * public-facing [InProgressStrokesView].
+ *
+ * A term used throughout this and related classes is a "cohort" of strokes. It refers to a group of
+ * strokes that are in progress simultaneously, and due to how low latency rendering works, **must**
+ * be handed off from in-progress to finished HWUI rendering all at the same time to avoid a flicker
+ * during the handoff. Two strokes are considered simultaneous and in the same cohort if they are
+ * present on screen during the same HWUI frame, even if the first stroke was finished earlier in
+ * the same frame than the second stroke was started. This can require a stroke to stay in progress
+ * longer than it may seem like it should, but this cohort boundary is required because handoff
+ * synchronization depends on HWUI frames while user inputs may happen multiple times per HWUI frame
+ * without a guaranteed order.
+ */
+@OptIn(ExperimentalLatencyDataApi::class)
+internal class InProgressStrokesManager(
+    private val inProgressStrokesRenderHelper: InProgressStrokesRenderHelper,
+    /** A lambda to run a [Runnable] on the next animation frame. */
+    private val postOnAnimation: (Runnable) -> Unit,
+    /** A lambda to run a [Runnable] on the next run loop of the UI thread. */
+    private val postToUiThread: (Runnable) -> Unit,
+    /** The callback for reporting latency data to the client. */
+    private val latencyDataCallback: LatencyDataCallback = LatencyDataCallback {},
+    /** For getting timestamps for latency measurement. Injectable for testing only. */
+    private inline val getNanoTime: () -> Long = System::nanoTime,
+    /** For getting instances of [InProgressStroke]. Injectable for testing only. */
+    inProgressStrokePool: InProgressStrokePool = InProgressStrokePool.create(),
+    /**
+     * Allows tests to replace [CountDownLatch.await] with something that yields rather than blocks.
+     */
+    private val blockingAwait: (CountDownLatch, Long, TimeUnit) -> Boolean =
+        { latch, timeout, timeoutUnit ->
+            latch.await(timeout, timeoutUnit)
+        },
+) : InProgressStrokesRenderHelper.Callback {
+
+    /**
+     * The transform matrix to convert input (MotionEvent) coordinates into coordinates of this view
+     * for rendering. Defaults to the identity matrix, for the case where LowLatencyView exactly
+     * overlays the view from which MotionEvents are being forwarded. This should only be set from
+     * the UI thread.
+     */
+    var motionEventToViewTransform: AndroidMatrix = AndroidMatrix()
+        get() = AndroidMatrix(field)
+        set(value) {
+            field.set(value)
+            queueInputToRenderThread(MotionEventToViewTransformAction(AndroidMatrix(value)))
+        }
+
+    /**
+     * Allows a test to easily wait until all in-progress strokes are completed and handed off.
+     * There is no reason to set this in non-test code. The recommended approach is to include this
+     * small object within production code, but actually registering it and making use of it would
+     * be exclusive to test code.
+     *
+     * https://developer.android.com/training/testing/espresso/idling-resource#integrate-recommended-approach
+     */
+    var inProgressStrokeCounter: CountingIdlingResource? = null
+
+    internal interface Listener {
+        /**
+         * Called when there are no longer any in-progress strokes. All strokes that were in
+         * progress simultaneously will be delivered in the same callback. This callback will
+         * execute on the UI thread. The implementer must ensure that by the time this callback
+         * function returns, these strokes are saved in a location where they will be picked up in a
+         * view's next call to [onDraw], and that view's [android.view.View.invalidate] method is
+         * called. Within the same UI thread run loop (HWUI frame), the provided
+         * [androidx.ink.strokes.Stroke] instances will no longer be rendered by this class. Failure
+         * to adhere to these guidelines will result in brief rendering errors when the stroke is
+         * finished - either a gap where the stroke is not drawn during a frame, or a double draw
+         * where the stroke is drawn twice and translucent strokes appear more opaque than they
+         * should.
+         */
+        @UiThread fun onAllStrokesFinished(strokes: Map<InProgressStrokeId, FinishedStroke>)
+    }
+
+    /**
+     * Pool of [LatencyData]s to be used and then recycled after handoff. These objects exist only
+     * for tracking and reporting the latency of the input processing pipeline.
+     */
+    private val latencyDataPool = LatencyDataPool()
+
+    /** The state that is accessed just by the UI thread. */
+    private val uiThreadState =
+        object {
+
+            /**
+             * Maps stroke IDs to the matrix [motionEventToStrokeTransform] and the stroke's
+             * [startEventTimeMillis]. This only contains strokes that have been started, but not
+             * yet finished or canceled.
+             */
+            val startedStrokes = mutableMapOf<InProgressStrokeId, UiStrokeState>()
+
+            /**
+             * This contains strokes that have been started and then finished, but not those that
+             * were canceled.
+             */
+            val inputCompletedStrokes = mutableSetOf<InProgressStrokeId>()
+
+            /**
+             * Strokes that have been finished and fully generated and are ready to be handed off.
+             */
+            val strokesAwaitingEndOfCohort = mutableMapOf<InProgressStrokeId, FinishedStroke>()
+
+            /**
+             * Runs [onEndOfStrokeCohortCheck] at most once per frame, even if this is passed to
+             * [postOnAnimation] more than once during that frame.
+             */
+            val checkEndOfStrokeCohortOnce = AtMostOnceAfterSetUp(::onEndOfStrokeCohortCheck)
+
+            var cohortHandoffDebounceTimeMs = 0L
+
+            var cohortHandoffAsap = false
+
+            var cohortHandoffPaused = false
+
+            var lastStrokeEndUptimeMs = Long.MIN_VALUE
+
+            val queueUpdateActionOnce = AtMostOnceAfterSetUp(::queueUpdateAction)
+
+            /** Strokes that have been canceled. */
+            val canceledStrokes = mutableSetOf<InProgressStrokeId>()
+
+            /** To notify when strokes have been completed. Owned by the UI thread. */
+            val listeners = mutableSetOf<Listener>()
+        }
+        @UiThread
+        get() {
+            return field
+        }
+
+    /** The state that is accessed just by the render thread. */
+    private val renderThreadState =
+        object {
+
+            /**
+             * Strokes that are being drawn by this class. This includes the contents of
+             * [generatedStrokes].
+             */
+            val toDrawStrokes = mutableMapOf<InProgressStrokeId, RenderThreadStrokeState>()
+
+            /**
+             * Strokes in [toDrawStrokes] whose inputs are finished, but which still need further
+             * calls to [updateShape] (e.g. due to time-since behaviors) before they will be fully
+             * dry.
+             */
+            val dryingStrokes = mutableSetOf<InProgressStrokeId>()
+
+            /**
+             * Strokes that have been fully generated, but not yet passed to the UI thread for
+             * client handoff.
+             */
+            val generatedStrokes = mutableMapOf<InProgressStrokeId, FinishedStroke>()
+
+            /**
+             * Strokes that have been canceled, thus should not be drawn or passed to the UI thread
+             * for client handoff.
+             */
+            val canceledStrokes = mutableSetOf<InProgressStrokeId>()
+
+            /**
+             * Contains instances of [InProgressStroke] that are not currently in use (does not
+             * belong to [toDrawStrokes]) and are ready to be used in a new stroke. This is to reuse
+             * memory that has already been allocated to improve performance after the first few
+             * strokes, and to minimize memory fragmentation that can affect the health of the app's
+             * process over time. This will grow as needed to match the size of the biggest stroke
+             * cohort seen in the last N handoffs. A hard limit on the pool size wouldn't be
+             * appropriate as each app and each user will have different patterns, and the value of
+             * [setHandoffDebounceTimeMs] will influence the number of [InProgressStroke] instances
+             * needed at once. But trimming the size of this pool according to recent activity (see
+             * [recentCohortSizes]) ensures that an unusually large cohort won't force too much
+             * memory to be held for the rest of the inking session.
+             */
+            val inProgressStrokePool = inProgressStrokePool
+
+            /**
+             * The N most recent values for how many [InProgressStroke] instances have been needed
+             * at once. Start with all zeroes because so far none have been needed.
+             */
+            val recentCohortSizes = IntArray(10)
+
+            /** The index in the [recentCohortSizes] circular buffer to update next. */
+            var recentCohortSizesNextIndex = 0
+
+            /**
+             * [LatencyData]s for the [InputAction]s that were processed in the latest call to
+             * [onDraw].
+             */
+            val latencyDatas: ArrayDeque<LatencyData> =
+                ArrayDeque<LatencyData>(initialCapacity = 30)
+
+            /**
+             * The render thread's copy of LowLatencyView.motionEventToViewTransform. This is a copy
+             * for thread safety.
+             */
+            val motionEventToViewTransform = AndroidMatrix()
+
+            /**
+             * Allocated once and reused on each draw to hold the result of a matrix multiplication.
+             */
+            val strokeToViewTransform = AndroidMatrix()
+
+            /**
+             * Pre-allocated list to contain actions that have been handled but need further
+             * processing. Used locally only in [onDraw].
+             */
+            val handledActions = arrayListOf<InputAction>()
+
+            /**
+             * Allocated once and reused multiple times per draw to hold updated areas of strokes.
+             */
+            val updatedRegion = BoxAccumulator()
+
+            /** Allocated once and reused multiple times per draw. */
+            val scratchEnvelope = BoxAccumulator()
+
+            /** Allocated once and reused multiple times per draw. */
+            val scratchRect = MutableBox()
+        }
+        @WorkerThread
+        get() {
+            assertOnRenderThread()
+            return field
+        }
+
+    /** The state that is accessed by more than one thread. Be careful here! */
+    private val threadSharedState =
+        object {
+            /**
+             * Finished strokes that have just been generated. Produced on the render thread and
+             * consumed on the UI thread.
+             */
+            val finishedStrokes =
+                ConcurrentLinkedQueue<Map.Entry<InProgressStrokeId, FinishedStroke>>()
+
+            /**
+             * Used to hand off input events across threads. This is added to from the UI thread
+             * when new inputs are given via the public functions, and consumed from the render
+             * thread when the contents of that event need to be rendered.
+             */
+            val inputActions = ConcurrentLinkedQueue<InputAction>()
+
+            /**
+             * Reuse input objects so they don't need to be constantly allocated for each input.
+             * This is added to from the render thread after it finishes processing an [AddAction],
+             * and consumed from the UI thread when [addToStroke] wants to reuse and fill in an
+             * [AddAction].
+             */
+            val addActionPool = ConcurrentLinkedQueue<AddAction>()
+            val strokeInputPool = StrokeInputPool()
+
+            /**
+             * Used to hand off finished [LatencyData]s from the render thread back to the UI thread
+             * for reporting to the client. This is added to from the render thread in
+             * [onFrontBufferedRenderComplete] and consumed from the UI thread in
+             * [handOffLatencyData].
+             */
+            val finishedLatencyDatas = ConcurrentLinkedQueue<LatencyData>()
+
+            val pauseInputs = AtomicBoolean(false)
+
+            val currentlyHandlingActions = AtomicBoolean(false)
+        }
+
+    /** Add a listener for when strokes have been completed. Must be called on the UI thread. */
+    @UiThread
+    fun addListener(listener: Listener) {
+        uiThreadState.listeners.add(listener)
+    }
+
+    /** Remove a listener for when strokes have been completed. Must be called on the UI thread. */
+    @UiThread
+    fun removeListener(listener: Listener) {
+        uiThreadState.listeners.remove(listener)
+    }
+
+    /**
+     * Start building a stroke with the [event] data at [pointerIndex].
+     *
+     * @param event The first [MotionEvent] as part of a Stroke's input data, typically an
+     *   ACTION_DOWN.
+     * @param pointerIndex The index of the relevant pointer in the [event].
+     * @param motionEventToWorldTransform The matrix that transforms [event] coordinates into the
+     *   client app's "world" coordinates, which typically is defined by how a client app's document
+     *   is panned/zoomed/rotated.
+     * @param strokeToWorldTransform An optional matrix that transforms this stroke into the client
+     *   app's "world" coordinates, which allows the coordinates of the stroke to be defined in
+     *   something other than world coordinates. Defaults to the identity matrix, in which case the
+     *   stroke coordinate space is the same as world coordinate space. This matrix must be
+     *   invertible.
+     * @param brush Brush specification for the stroke being started.
+     * @param strokeUnitLengthCm The physical distance that the pointer must travel in order to
+     *   produce an input motion of one stroke unit for this particular stroke, in centimeters.
+     * @return The Stroke ID of the stroke being built, later used to identify which stroke is being
+     *   added to, finished, or canceled.
+     * @throws IllegalArgumentException if [strokeToWorldTransform] is not invertible.
+     */
+    @UiThread
+    fun startStroke(
+        event: MotionEvent,
+        pointerIndex: Int,
+        motionEventToWorldTransform: AndroidMatrix,
+        strokeToWorldTransform: AndroidMatrix,
+        brush: Brush,
+        strokeUnitLengthCm: Float,
+    ): InProgressStrokeId {
+        val receivedActionTimeNanos = getNanoTime()
+        // Set up this stroke's matrix to be used to transform MotionEvent -> stroke coordinates.
+        val motionEventToStrokeTransform =
+            AndroidMatrix().also {
+                // Compute (world -> stroke) = (stroke -> world)^-1
+                require(strokeToWorldTransform.invert(it)) {
+                    "strokeToWorldTransform must be invertible, but was $strokeToWorldTransform"
+                }
+                // Compute (MotionEvent -> stroke) = (world -> stroke) x (MotionEvent -> world)
+                it.preConcat(motionEventToWorldTransform)
+            }
+        val strokeId = InProgressStrokeId.create()
+        return startStrokeInternal(
+            // This ignores any historical inputs included in this MotionEvent. ACTION_DOWN doesn't
+            // have any, and if a user is passing ACTION_MOVE to startStroke, this assumes the
+            // stroke
+            // starts at (eventTime, x, y), ignoring any historical inputs between that and the
+            // previous MotionEvent.
+            threadSharedState.strokeInputPool.obtainSingleValueForMotionEvent(
+                event,
+                pointerIndex,
+                motionEventToStrokeTransform,
+                event.eventTime,
+                strokeUnitLengthCm,
+            ),
+            brush,
+            event.eventTime,
+            strokeId = strokeId,
+            inputToStrokeTransform = motionEventToStrokeTransform,
+            latencyData =
+                latencyDataPool.obtainLatencyDataForSingleEvent(
+                    event,
+                    LatencyData.StrokeAction.START,
+                    strokeId,
+                    receivedActionTimeNanos,
+                ),
+        )
+    }
+
+    /**
+     * Start building a stroke with the provided [input].
+     *
+     * @param input The first input in a stroke.
+     * @param brush Brush specification for the stroke being started.
+     * @param startTimeMillis Start time of the stroke, used to determine the relative timing of
+     *   later additions of the stroke.
+     * @return The Stroke ID of the stroke being built, later used to identify which stroke is being
+     *   added to, finished, or canceled.
+     */
+    @UiThread
+    fun startStroke(input: StrokeInput, brush: Brush): InProgressStrokeId {
+        // The start time here isn't really relevant unless this override of startStroke is combined
+        // with the MotionEvent override of addToStroke or finishStroke.
+        return startStrokeInternal(input, brush, getNanoTime() / 1_000_000L)
+    }
+
+    @UiThread
+    private fun startStrokeInternal(
+        input: StrokeInput,
+        brush: Brush,
+        startTimeMillis: Long,
+        strokeId: InProgressStrokeId = InProgressStrokeId.create(),
+        inputToStrokeTransform: AndroidMatrix = AndroidMatrix(),
+        // TODO: b/364655356 - Add support for collecting LatencyData in the
+        // StrokeInput[Batch]-based
+        // API.
+        latencyData: LatencyData? = null,
+    ): InProgressStrokeId {
+        inProgressStrokeCounter?.increment()
+        val strokeState =
+            UiStrokeState(inputToStrokeTransform, startTimeMillis, input.strokeUnitLengthCm)
+        uiThreadState.startedStrokes[strokeId] = strokeState
+        val startAction =
+            StartAction(
+                input,
+                strokeId,
+                inputToStrokeTransform,
+                brush,
+                latencyData,
+                startTimeMillis
+            )
+        queueInputToRenderThread(startAction)
+        return startAction.strokeId
+    }
+
+    /**
+     * Add [event] data at [pointerIndex] to already started stroke with [strokeId].
+     *
+     * @param event the next [MotionEvent] as part of a Stroke's input data, typically an
+     *   ACTION_MOVE.
+     * @param pointerIndex the index of the relevant pointer in the [event].
+     * @param strokeId the Stroke that is to be built upon with [event].
+     * @param prediction optional predicted MotionEvent containing predicted inputs between event
+     *   and the time of the next frame, as generated by MotionEventPredictor::predict.
+     */
+    @UiThread
+    fun addToStroke(
+        event: MotionEvent,
+        pointerIndex: Int,
+        strokeId: InProgressStrokeId,
+        prediction: MotionEvent?,
+    ) {
+        val receivedActionTimeNanos = getNanoTime()
+        val strokeState = uiThreadState.startedStrokes[strokeId]
+        checkNotNull(strokeState) { "Stroke with ID $strokeId was not found." }
+        val addAction =
+            (threadSharedState.addActionPool.poll() ?: AddAction()).apply {
+                check(realInputs.isEmpty())
+                check(realInputLatencyDatas.isEmpty())
+                threadSharedState.strokeInputPool.obtainAllHistoryForMotionEvent(
+                    event = event,
+                    pointerIndex = pointerIndex,
+                    motionEventToStrokeTransform = strokeState.motionEventToStrokeTransform,
+                    strokeStartTimeMillis = strokeState.startEventTimeMillis,
+                    strokeUnitLengthCm = strokeState.strokeUnitLengthCm,
+                    outBatch = realInputs,
+                )
+                // TODO b/306361370 - Generate LatencyData only for those inputs that pass
+                // validation.
+                if (!realInputs.isEmpty()) {
+                    latencyDataPool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+                        event,
+                        LatencyData.StrokeAction.ADD,
+                        strokeId,
+                        receivedActionTimeNanos,
+                        predicted = false,
+                        realInputLatencyDatas,
+                    )
+                }
+                check(predictedInputs.isEmpty())
+                check(predictedInputLatencyDatas.isEmpty())
+                if (prediction != null) {
+                    threadSharedState.strokeInputPool.obtainAllHistoryForMotionEvent(
+                        event = prediction,
+                        pointerIndex = pointerIndex,
+                        motionEventToStrokeTransform = strokeState.motionEventToStrokeTransform,
+                        strokeStartTimeMillis = strokeState.startEventTimeMillis,
+                        strokeUnitLengthCm = strokeState.strokeUnitLengthCm,
+                        outBatch = predictedInputs,
+                    )
+                    // TODO b/306361370 - Generate LatencyData only for those inputs that pass
+                    // validation.
+                    if (!predictedInputs.isEmpty()) {
+                        latencyDataPool.obtainLatencyDataForPrimaryAndHistoricalEvents(
+                            prediction,
+                            LatencyData.StrokeAction.PREDICTED_ADD,
+                            strokeId,
+                            receivedActionTimeNanos,
+                            predicted = true,
+                            predictedInputLatencyDatas,
+                        )
+                    }
+                }
+                this.strokeId = strokeId
+            }
+        queueAddActionIfNonEmpty(addAction)
+    }
+
+    /**
+     * Add [inputs] to already started stroke with [strokeId].
+     *
+     * @param inputs the next set of real inputs to extend the stroke.
+     * @param strokeId the Stroke that is to be built upon with [inputs].
+     * @param prediction optional predicted inputs.
+     */
+    @UiThread
+    fun addToStroke(
+        inputs: StrokeInputBatch,
+        strokeId: InProgressStrokeId,
+        prediction: StrokeInputBatch,
+    ) {
+        val strokeState = uiThreadState.startedStrokes[strokeId]
+        checkNotNull(strokeState) { "Stroke with ID $strokeId was not found." }
+        val addAction =
+            (threadSharedState.addActionPool.poll() ?: AddAction()).apply {
+                check(realInputs.isEmpty())
+                check(realInputLatencyDatas.isEmpty())
+                check(predictedInputs.isEmpty())
+                check(predictedInputLatencyDatas.isEmpty())
+                realInputs.addOrIgnore(inputs)
+                predictedInputs.addOrIgnore(prediction)
+                this.strokeId = strokeId
+            }
+        queueAddActionIfNonEmpty(addAction)
+    }
+
+    @UiThread
+    private fun queueAddActionIfNonEmpty(addAction: AddAction) {
+        // If both real and predicted input batches have no valid inputs, return early.
+        if (addAction.realInputs.isEmpty() && addAction.predictedInputs.isEmpty()) {
+            threadSharedState.addActionPool.offer(addAction)
+            return
+        }
+        queueInputToRenderThread(addAction)
+    }
+
+    /**
+     * Complete the building of a stroke.
+     *
+     * @param event the last [MotionEvent] as part of a stroke, typically an ACTION_UP.
+     * @param pointerIndex the index of the relevant pointer.
+     * @param strokeId the stroke that is to be finished with the latest event.
+     */
+    @UiThread
+    fun finishStroke(event: MotionEvent, pointerIndex: Int, strokeId: InProgressStrokeId) {
+        val receivedActionTimeNanos = getNanoTime()
+        val strokeState = uiThreadState.startedStrokes[strokeId] ?: return
+        finishStrokeInternal(
+            // This ignores any historical inputs included in this MotionEvent. Typically, this
+            // is called in response to an ACTION_UP, which doesn't have any. But potentially the
+            // logic could be made to take those into account if present.
+            threadSharedState.strokeInputPool.obtainSingleValueForMotionEvent(
+                event,
+                pointerIndex,
+                strokeState.motionEventToStrokeTransform,
+                strokeState.startEventTimeMillis,
+                strokeState.strokeUnitLengthCm,
+            ),
+            strokeId,
+            endTimeMs = event.eventTime,
+            latencyData =
+                latencyDataPool.obtainLatencyDataForSingleEvent(
+                    event,
+                    LatencyData.StrokeAction.FINISH,
+                    strokeId,
+                    receivedActionTimeNanos,
+                ),
+        )
+    }
+
+    /**
+     * Complete the building of a stroke.
+     *
+     * @param input the last [StrokeInput] in a stroke.
+     * @param strokeId the stroke that is to be finished with that input.
+     */
+    @UiThread
+    fun finishStroke(input: StrokeInput, strokeId: InProgressStrokeId) {
+        finishStrokeInternal(input, strokeId, getNanoTime() / 1_000_000L)
+    }
+
+    @UiThread
+    private fun finishStrokeInternal(
+        input: StrokeInput?,
+        strokeId: InProgressStrokeId,
+        endTimeMs: Long,
+        latencyData: LatencyData? = null,
+    ) {
+        val finishAction = FinishAction(input, strokeId, latencyData)
+        uiThreadState.lastStrokeEndUptimeMs = endTimeMs
+        uiThreadState.startedStrokes.remove(strokeId)
+        uiThreadState.inputCompletedStrokes.add(strokeId)
+        queueInputToRenderThread(finishAction)
+    }
+
+    /**
+     * Cancel the building of a stroke.
+     *
+     * @param strokeId the stroke to cancel.
+     */
+    @UiThread
+    fun cancelStroke(strokeId: InProgressStrokeId, event: MotionEvent?) {
+        val receivedActionTimeNanos = getNanoTime()
+        uiThreadState.startedStrokes.remove(strokeId) ?: return
+        uiThreadState.lastStrokeEndUptimeMs = receivedActionTimeNanos / 1_000_000
+        uiThreadState.canceledStrokes.add(strokeId)
+        val cancelAction =
+            CancelAction(
+                strokeId,
+                latencyDataPool.obtainLatencyDataForSingleEvent(
+                    event,
+                    LatencyData.StrokeAction.CANCEL,
+                    strokeId,
+                    receivedActionTimeNanos,
+                ),
+            )
+        queueInputToRenderThread(cancelAction)
+    }
+
+    /**
+     * Begin the process of a possible handoff. If a handoff is actually possible right now, then
+     * [Finished] will be returned containing the strokes to hand off, and state will be updated to
+     * ensure that those strokes are not held anywhere else. If a handoff is not possible right now,
+     * then a different type of [ClaimStrokesToHandOffResult] will be returned to indicate the
+     * reason. The reason why a handoff cannot happen right now determines the next steps, mostly
+     * whether a task should be scheduled to check again in a short period of time, or whether more
+     * external input is needed to change the state.
+     */
+    @CheckResult
+    @UiThread
+    private fun claimStrokesToHandOff(): ClaimStrokesToHandOffResult {
+        // First, make sure that any finished (input complete and fully generated) strokes that the
+        // render thread is done with are added to strokesAwaitingEndOfCohort.
+        while (threadSharedState.finishedStrokes.isNotEmpty()) {
+            // finishedStrokes was just confirmed to not be empty, so polling it should never return
+            // null.
+            // This wouldn't necessarily be true in all multithreaded scenarios, but for
+            // finishedStrokes,
+            // items are only ever removed from it by the UI thread, and the render thread only ever
+            // adds
+            // items to it, so there is not another thread that could have come in and removed items
+            // between isEmpty and poll.
+            val (strokeId, finishedStroke) = checkNotNull(threadSharedState.finishedStrokes.poll())
+            uiThreadState.inputCompletedStrokes.remove(strokeId)
+            if (!uiThreadState.canceledStrokes.contains(strokeId)) {
+                uiThreadState.strokesAwaitingEndOfCohort[strokeId] = finishedStroke
+            }
+        }
+
+        // Check that all strokes currently being rendered are finished (input complete and fully
+        // generated) and ready to be handed off.
+        if (
+            uiThreadState.startedStrokes.isEmpty() && uiThreadState.inputCompletedStrokes.isEmpty()
+        ) {
+            if (uiThreadState.strokesAwaitingEndOfCohort.isEmpty()) {
+                return NoneInProgressOrFinished
+            }
+            if (uiThreadState.cohortHandoffPaused) {
+                return NoneInProgressButHandoffsPaused
+            }
+            if (
+                inProgressStrokesRenderHelper.supportsDebounce &&
+                    !uiThreadState.cohortHandoffAsap &&
+                    getNanoTime() / 1_000_000 <
+                        uiThreadState.lastStrokeEndUptimeMs +
+                            uiThreadState.cohortHandoffDebounceTimeMs
+            ) {
+                return NoneInProgressButDebouncing
+            }
+            val handingOff = uiThreadState.strokesAwaitingEndOfCohort.toMap()
+            uiThreadState.strokesAwaitingEndOfCohort.clear()
+            return Finished(handingOff)
+        }
+        return StillInProgress
+    }
+
+    @UiThread
+    private fun onEndOfStrokeCohortCheck() {
+        val claimStrokesToHandOffResult = claimStrokesToHandOff()
+        if (claimStrokesToHandOffResult !is Finished) {
+            if (claimStrokesToHandOffResult is NoneInProgressButDebouncing) {
+                potentialEndOfStrokeCohort()
+            }
+            return
+        }
+
+        uiThreadState.cohortHandoffAsap = false
+        uiThreadState.lastStrokeEndUptimeMs = Long.MIN_VALUE
+
+        uiThreadState.strokesAwaitingEndOfCohort.clear()
+
+        threadSharedState.pauseInputs.set(true)
+        // Queue a clear action to take place as soon as inputs are unpaused, to be sure the clear
+        // happens before any inputs for the new cohort.
+        queueInputToRenderThread(ClearAction)
+        inProgressStrokesRenderHelper.requestStrokeCohortHandoffToHwui(
+            claimStrokesToHandOffResult.finishedStrokes
+        )
+    }
+
+    @UiThread
+    fun setHandoffDebounceTimeMs(debounceTimeMs: Long) {
+        if (!inProgressStrokesRenderHelper.supportsDebounce) {
+            return
+        }
+        uiThreadState.cohortHandoffDebounceTimeMs = debounceTimeMs
+        potentialEndOfStrokeCohort()
+    }
+
+    /**
+     * Request that the value passed to [setHandoffDebounceTimeMs] be temporarily ignored to hand
+     * off rendering to the client's dry layer via
+     * [InProgressStrokesFinishedListener.onStrokesFinished]. Afterwards, handoff debouncing will
+     * resume as normal.
+     *
+     * This API is experimental for now, as one approach to address start-of-stroke latency for fast
+     * subsequent strokes.
+     */
+    @UiThread
+    fun requestImmediateHandoff() {
+        uiThreadState.cohortHandoffAsap = true
+        potentialEndOfStrokeCohort()
+    }
+
+    /**
+     * Make a best effort to finish or cancel all in-progress strokes, and if appropriate, execute
+     * [Listener.onAllStrokesFinished] synchronously. This must be called on the UI thread, and
+     * blocks it, so this should only be used in synchronous shutdown scenarios.
+     *
+     * @return `true` if and only if the flush completed successfully. Note that not all
+     *   configurations support flushing, and flushing is best effort, so this is not guaranteed to
+     *   return `true`.
+     */
+    @UiThread
+    fun flush(timeout: Long, timeoutUnit: TimeUnit, cancelAllInProgress: Boolean): Boolean {
+        if (!inProgressStrokesRenderHelper.supportsFlush) {
+            return false
+        }
+        // cancelStroke/finishStroke will modify uiThreadState.startedStrokes, so make a copy to
+        // avoid
+        // a ConcurrentModificationException.
+        for (id in uiThreadState.startedStrokes.keys.toList()) {
+            if (cancelAllInProgress) {
+                cancelStroke(id, event = null)
+            } else {
+                finishStrokeInternal(
+                    input = null,
+                    strokeId = id,
+                    endTimeMs = getNanoTime() / 1_000_000
+                )
+            }
+        }
+        if (
+            threadSharedState.inputActions.isNotEmpty() ||
+                threadSharedState.currentlyHandlingActions.get()
+        ) {
+            threadSharedState.pauseInputs.set(false)
+            val flushAction = FlushAction()
+            queueInputToRenderThread(flushAction)
+            blockingAwait(flushAction.flushCompleted, timeout, timeoutUnit)
+        }
+        uiThreadState.cohortHandoffAsap = true
+        uiThreadState.cohortHandoffPaused = false
+        // It's unlikely that the result would be anything other than Finished, but it's possible
+        // with
+        // a short enough timeout.
+        return when (val claimStrokesToHandOffResult = claimStrokesToHandOff()) {
+            is Finished -> {
+                onStrokeCohortHandoffToHwui(claimStrokesToHandOffResult.finishedStrokes)
+                true
+            }
+            // None left in progress, so the flush completed successfully, but nothing to hand off.
+            is NoneInProgressOrFinished -> true
+            // Some strokes were still left in progress.
+            else -> false
+        }
+    }
+
+    @UiThread
+    fun sync(timeout: Long, timeoutUnit: TimeUnit) {
+        if (!inProgressStrokesRenderHelper.supportsFlush) {
+            return
+        }
+        val syncAction = SyncAction()
+        queueInputToRenderThread(syncAction)
+        blockingAwait(syncAction.syncCompleted, timeout, timeoutUnit)
+    }
+
+    @UiThread
+    override fun setPauseStrokeCohortHandoffs(paused: Boolean) {
+        val oldPaused = uiThreadState.cohortHandoffPaused
+        uiThreadState.cohortHandoffPaused = paused
+        if (oldPaused && !paused) {
+            potentialEndOfStrokeCohort()
+        }
+    }
+
+    @UiThread
+    override fun onStrokeCohortHandoffToHwui(
+        strokeCohort: Map<InProgressStrokeId, FinishedStroke>
+    ) {
+        for (listener in uiThreadState.listeners) {
+            listener.onAllStrokesFinished(strokeCohort)
+        }
+        inProgressStrokeCounter?.let { counter ->
+            repeat(strokeCohort.size) { counter.decrement() }
+        }
+    }
+
+    @UiThread
+    override fun onStrokeCohortHandoffToHwuiComplete() {
+        threadSharedState.pauseInputs.set(false)
+        inProgressStrokesRenderHelper.requestDraw()
+    }
+
+    /**
+     * Queue the [inputAction] to the render thread, then request a frontbuffer redraw. Frontbuffer
+     * redraws consume all queued input actions.
+     */
+    @UiThread
+    private fun queueInputToRenderThread(input: InputAction) {
+        threadSharedState.inputActions.offer(input)
+        if (!threadSharedState.pauseInputs.get()) {
+            inProgressStrokesRenderHelper.requestDraw()
+        }
+    }
+
+    @WorkerThread
+    private fun handleAction(action: InputAction) {
+        assertOnRenderThread()
+        when (action) {
+            is StartAction -> handleStartStroke(action)
+            is AddAction -> handleAddToStroke(action)
+            is FinishAction -> handleFinishStroke(action)
+            is UpdateAction -> handleUpdateStrokes()
+            is CancelAction -> handleCancelStroke(action)
+            is MotionEventToViewTransformAction -> handleMotionEventToViewTransformAction(action)
+            is ClearAction -> handleClear()
+            is FlushAction -> handleFlushAction(action)
+            // Nothing to do before drawing for [SyncAction].
+            else -> {}
+        }
+    }
+
+    @WorkerThread
+    private fun handleActionAfterDraw(action: InputAction) {
+        assertOnRenderThread()
+        when (action) {
+            is FinishAction -> handleFinishStrokeAfterDraw()
+            is UpdateAction -> handleUpdateStrokesAfterDraw()
+            is CancelAction -> handleCancelStrokeAfterDraw(action)
+            is SyncAction -> handleSyncActionAfterDraw(action)
+            // Nothing to do after drawing for the other actions.
+            else -> {}
+        }
+    }
+
+    /** Handle an action that was initiated by [startStroke]. */
+    @WorkerThread
+    private fun handleStartStroke(action: StartAction) {
+        assertOnRenderThread()
+        val strokeToMotionEventTransform =
+            AndroidMatrix().apply { action.motionEventToStrokeTransform.invert(this) }
+        val strokeState = run {
+            val stroke = renderThreadState.inProgressStrokePool.obtain()
+            stroke.start(action.brush)
+            stroke
+                .enqueueInputs(
+                    MutableStrokeInputBatch().addOrIgnore(action.strokeInput),
+                    ImmutableStrokeInputBatch.EMPTY,
+                )
+                .onFailure {
+                    // TODO(b/306361370): Throw here once input is more sanitized.
+                    Log.w(
+                        InProgressStrokesManager::class.simpleName,
+                        "Error during InProgressStroke.enqueueInputs",
+                        it,
+                    )
+                }
+            stroke.updateShape(0).onFailure {
+                // TODO(b/306361370): Throw here once input is more sanitized.
+                Log.w(
+                    InProgressStrokesManager::class.simpleName,
+                    "Error during InProgressStroke.updateShape",
+                    it,
+                )
+            }
+            RenderThreadStrokeState(
+                stroke,
+                strokeToMotionEventTransform,
+                startEventTimeMillis = action.startEventTimeMillis,
+            )
+        }
+        threadSharedState.strokeInputPool.recycle(action.strokeInput)
+        renderThreadState.toDrawStrokes[action.strokeId] = strokeState
+        action.latencyData?.let { renderThreadState.latencyDatas.add(it) }
+    }
+
+    /** Handle an action that was initiated by [addToStroke]. */
+    @WorkerThread
+    private fun handleAddToStroke(action: AddAction) {
+        assertOnRenderThread()
+        val strokeState = renderThreadState.toDrawStrokes[action.strokeId]
+        checkNotNull(strokeState) { "Stroke state with ID ${action.strokeId} was not found." }
+        check(!renderThreadState.generatedStrokes.contains(action.strokeId)) {
+            "Stroke with ID ${action.strokeId} was already finished."
+        }
+        check(!renderThreadState.canceledStrokes.contains(action.strokeId)) {
+            "Stroke with ID ${action.strokeId} was canceled."
+        }
+        strokeState.inProgressStroke.apply {
+            enqueueInputs(action.realInputs, action.predictedInputs).onFailure {
+                // TODO(b/306361370): Throw here once input is more sanitized.
+                Log.w(
+                    InProgressStrokesManager::class.simpleName,
+                    "Error during InProgressStroke.enqueueInputs",
+                    it,
+                )
+            }
+            // TODO: b/287041801 - Don't necessarily always immediately [updateShape] after
+            // [enqueueInputs].
+            updateShape(getNanoTime() / 1_000_000L - strokeState.startEventTimeMillis).onFailure {
+                // TODO(b/306361370): Throw here once input is more sanitized.
+                Log.w(
+                    InProgressStrokesManager::class.simpleName,
+                    "Error during InProgressStroke.updateShape",
+                    it,
+                )
+            }
+        }
+        action.realInputs.clear()
+        action.predictedInputs.clear()
+        while (!action.realInputLatencyDatas.isEmpty()) {
+            renderThreadState.latencyDatas.add(action.realInputLatencyDatas.removeFirst())
+        }
+        while (!action.predictedInputLatencyDatas.isEmpty()) {
+            renderThreadState.latencyDatas.add(action.predictedInputLatencyDatas.removeFirst())
+        }
+        threadSharedState.addActionPool.offer(action)
+    }
+
+    /** Handle an action that was initiated by [finishStroke]. */
+    @WorkerThread
+    private fun handleFinishStroke(action: FinishAction) {
+        assertOnRenderThread()
+        val strokeState = renderThreadState.toDrawStrokes[action.strokeId]
+        checkNotNull(strokeState) { "Stroke state with ID ${action.strokeId} was not found." }
+        check(!renderThreadState.generatedStrokes.contains(action.strokeId)) {
+            "Stroke with ID ${action.strokeId} was already finished."
+        }
+        check(!renderThreadState.canceledStrokes.contains(action.strokeId)) {
+            "Stroke with ID ${action.strokeId} was canceled."
+        }
+        fillStrokeToViewTransform(strokeState)
+        val copiedStrokeToViewTransform =
+            AndroidMatrix().apply { set(renderThreadState.strokeToViewTransform) }
+        // Save the stroke to be handed off.
+        if (action.strokeInput != null) {
+            strokeState.inProgressStroke
+                .enqueueInputs(
+                    MutableStrokeInputBatch().addOrIgnore(action.strokeInput),
+                    ImmutableStrokeInputBatch.EMPTY,
+                )
+                .onFailure {
+                    // TODO(b/306361370): Throw here once input is more sanitized.
+                    Log.w(
+                        InProgressStrokesManager::class.simpleName,
+                        "Error during InProgressStroke.enqueueInputs",
+                        it,
+                    )
+                }
+            // TODO: b/287041801 - Don't necessarily always immediately [updateShape] after
+            // [enqueueInputs].
+            strokeState.inProgressStroke
+                .updateShape(getNanoTime() / 1_000_000L - strokeState.startEventTimeMillis)
+                .onFailure {
+                    // TODO(b/306361370): Throw here once input is more sanitized.
+                    Log.w(
+                        InProgressStrokesManager::class.simpleName,
+                        "Error during InProgressStroke.updateShape",
+                        it,
+                    )
+                }
+        }
+        strokeState.inProgressStroke.finishInput()
+        if (strokeState.inProgressStroke.getNeedsUpdate()) {
+            renderThreadState.dryingStrokes.add(action.strokeId)
+            postToUiThread(::scheduleUpdateAction)
+        } else {
+            renderThreadState.generatedStrokes[action.strokeId] =
+                FinishedStroke(
+                    stroke = strokeState.inProgressStroke.toImmutable(),
+                    copiedStrokeToViewTransform,
+                )
+        }
+        if (action.strokeInput != null) {
+            threadSharedState.strokeInputPool.recycle(action.strokeInput)
+        }
+        action.latencyData?.let { renderThreadState.latencyDatas.add(it) }
+        // Clean up state and notify the UI thread of the potential end of this cohort after
+        // drawing.
+    }
+
+    @WorkerThread
+    private fun handleFinishStrokeAfterDraw() {
+        moveGeneratedStrokesToFinishedStrokes()
+    }
+
+    @WorkerThread
+    private fun handleUpdateStrokes() {
+        val nowMillis = getNanoTime() / 1_000_000L
+        val dryingStrokesIterator = renderThreadState.dryingStrokes.iterator()
+        for (strokeId in dryingStrokesIterator) {
+            val strokeState = renderThreadState.toDrawStrokes[strokeId]
+            checkNotNull(strokeState) { "Stroke state with ID ${strokeId} was not found." }
+            val inProgressStroke = strokeState.inProgressStroke
+
+            inProgressStroke.updateShape(nowMillis - strokeState.startEventTimeMillis).onFailure {
+                // TODO(b/306361370): Throw here once input is more sanitized.
+                Log.w(
+                    InProgressStrokesManager::class.simpleName,
+                    "Error during InProgressStroke.updateShape",
+                    it,
+                )
+            }
+
+            // If the stroke is now fully dry, remove it from [dryingStrokes] and mark it finished.
+            if (!inProgressStroke.getNeedsUpdate()) {
+                dryingStrokesIterator.remove()
+                fillStrokeToViewTransform(strokeState)
+                val copiedStrokeToViewTransform =
+                    AndroidMatrix().apply { set(renderThreadState.strokeToViewTransform) }
+                renderThreadState.generatedStrokes[strokeId] =
+                    FinishedStroke(
+                        stroke = inProgressStroke.toImmutable(),
+                        copiedStrokeToViewTransform,
+                    )
+            }
+        }
+
+        // Schedule another [UpdateAction] if needed.
+        if (!renderThreadState.dryingStrokes.isEmpty()) {
+            postToUiThread(::scheduleUpdateAction)
+        }
+    }
+
+    /**
+     * Arranges to queue an [UpdateAction] on the next animation frame. If this is called multiple
+     * times between animation frames, only one [UpdateAction] will be queued.
+     */
+    @UiThread
+    private fun scheduleUpdateAction() {
+        postOnAnimation(uiThreadState.queueUpdateActionOnce.setUp())
+    }
+
+    /**
+     * Queues an [UpdateAction] to the render thread. This is the implementation for
+     * [queueUpdateActionOnce]; use that instead of calling this directly.
+     */
+    @UiThread
+    private fun queueUpdateAction() {
+        queueInputToRenderThread(UpdateAction)
+    }
+
+    @WorkerThread
+    private fun handleUpdateStrokesAfterDraw() {
+        moveGeneratedStrokesToFinishedStrokes()
+    }
+
+    /**
+     * Moves ownership of the generated strokes from the render thread to the UI thread, and
+     * notifies the UI thread of the potential end of this stroke cohort, but keeps the in-progress
+     * version of those strokes in [toDrawStrokes] so they can continue to be drawn as wet strokes
+     * until the UI thread actually ends this stroke cohort.
+     */
+    @WorkerThread
+    private fun moveGeneratedStrokesToFinishedStrokes() {
+        threadSharedState.finishedStrokes.addAll(renderThreadState.generatedStrokes.asIterable())
+        renderThreadState.generatedStrokes.clear()
+        postToUiThread(::potentialEndOfStrokeCohort)
+    }
+
+    /** Handle an action that was initiated by [cancelStroke]. */
+    @WorkerThread
+    private fun handleCancelStroke(action: CancelAction) {
+        assertOnRenderThread()
+        checkNotNull(renderThreadState.toDrawStrokes[action.strokeId]) {
+            "Stroke state with ID ${action.strokeId} was not found."
+        }
+        // Mark the stroke as canceled just for the draw step so it can be cleared, and then forget
+        // about it entirely in handleCancelStrokeAfterDraw.
+        renderThreadState.canceledStrokes.add(action.strokeId)
+        // If it was already finished but not yet handed off, can still cancel it.
+        renderThreadState.generatedStrokes.remove(action.strokeId)
+        // Don't save the stroke to be handed off as in handleFinishStroke.
+        renderThreadState.latencyDatas.add(action.latencyData)
+        // Clean up state and possibly send callbacks after drawing.
+    }
+
+    @WorkerThread
+    private fun handleCancelStrokeAfterDraw(action: CancelAction) {
+        // Remove its state since we won't be adding to it anymore and it no longer should be drawn.
+        val removedStrokeState = renderThreadState.toDrawStrokes.remove(action.strokeId)
+        if (removedStrokeState != null) {
+            renderThreadState.inProgressStrokePool.recycle(removedStrokeState.inProgressStroke)
+        }
+
+        inProgressStrokeCounter?.decrement()
+
+        postToUiThread(::potentialEndOfStrokeCohort)
+    }
+
+    @UiThread
+    private fun potentialEndOfStrokeCohort() {
+        // This may be the end of the current cohort of strokes, but wait until all inputs have been
+        // processed in a HWUI frame (in onAnimation) to ensure that any strokes that are present in
+        // the
+        // same frame are considered part of the same cohort.
+        postOnAnimation(uiThreadState.checkEndOfStrokeCohortOnce.setUp())
+    }
+
+    /** Handle an action that was initiated by setting [motionEventToViewTransform]. */
+    @WorkerThread
+    private fun handleMotionEventToViewTransformAction(action: MotionEventToViewTransformAction) {
+        assertOnRenderThread()
+        renderThreadState.motionEventToViewTransform.set(action.motionEventToViewTransform)
+    }
+
+    @WorkerThread
+    private fun handleClear() {
+        assertOnRenderThread()
+        val cohortSize = renderThreadState.toDrawStrokes.size
+        // Recycle instances of InProgressStroke.
+        for (strokeState in renderThreadState.toDrawStrokes.values) {
+            renderThreadState.inProgressStrokePool.recycle(strokeState.inProgressStroke)
+        }
+
+        // Clear state.
+        renderThreadState.toDrawStrokes.clear()
+        renderThreadState.generatedStrokes.clear()
+        renderThreadState.canceledStrokes.clear()
+        if (inProgressStrokesRenderHelper.contentsPreservedBetweenDraws) {
+            inProgressStrokesRenderHelper.clear()
+        }
+
+        // Make sure we're holding onto a reasonable number of InProgressStroke instances, as
+        // determined
+        // by recent data on how many are needed simultaneously based on app and user behavior.
+        renderThreadState.recentCohortSizes[renderThreadState.recentCohortSizesNextIndex] =
+            cohortSize
+        renderThreadState.recentCohortSizesNextIndex++
+        if (
+            renderThreadState.recentCohortSizesNextIndex >= renderThreadState.recentCohortSizes.size
+        ) {
+            renderThreadState.recentCohortSizesNextIndex = 0
+        }
+        val maxRecentCohortSize = renderThreadState.recentCohortSizes.max()
+        renderThreadState.inProgressStrokePool.trimToSize(maxRecentCohortSize)
+    }
+
+    @WorkerThread
+    private fun handleFlushAction(action: FlushAction) {
+        action.flushCompleted.countDown()
+    }
+
+    @WorkerThread
+    private fun handleSyncActionAfterDraw(action: SyncAction) {
+        action.syncCompleted.countDown()
+    }
+
+    /** Called by the [InProgressStrokesRenderHelper] when it can be drawn to. */
+    @WorkerThread
+    override fun onDraw() {
+        assertOnRenderThread()
+        check(renderThreadState.handledActions.isEmpty())
+        // Skip drawing until input is unpaused.
+        if (threadSharedState.pauseInputs.get()) return
+        threadSharedState.currentlyHandlingActions.set(true)
+        // Process all available events in case any were added when the front buffer was not
+        // available
+        // (before onAttachedToWindow).
+        while (threadSharedState.inputActions.isNotEmpty()) {
+            val nextInputAction = threadSharedState.inputActions.poll()
+            // Even though the isNotEmpty check and the poll are not synchronized with one another
+            // and in
+            // a fully multi-threaded scenario it would be possible for the poll to be null after
+            // checking
+            // isNotEmpty, in our use case the render thread is the only one removing items from
+            // this
+            // queue so there should be no way for the queue to be empty by the time we poll it.
+            checkNotNull(nextInputAction) {
+                "requestRender was called without adding input action."
+            }
+            handleAction(nextInputAction)
+            renderThreadState.handledActions.add(nextInputAction)
+        }
+
+        if (inProgressStrokesRenderHelper.contentsPreservedBetweenDraws) {
+            // The updated region for each stroke must be drawn into for all strokes, not just
+            // itself, to
+            // handle when a live stroke intersects another live stroke. Without this nested loop
+            // (if
+            // scissor+draw happened for each stroke in isolation), a live stroke A drawing over
+            // another
+            // live stroke B would clear a rectangle where B was previously drawn and only draw A in
+            // that
+            // space - but that part of B needs to be filled in again.
+            for ((strokeIdToScissor, strokeStateToScissor) in renderThreadState.toDrawStrokes) {
+                fillUpdatedStrokeRegion(strokeIdToScissor, strokeStateToScissor)
+                val updatedRegionBox = renderThreadState.updatedRegion.box
+                if (updatedRegionBox != null) {
+                    renderThreadState.scratchRect.populateFrom(updatedRegionBox)
+                    // Change updatedRegion from stroke coordinates to view coordinates.
+                    fillStrokeToViewTransform(strokeStateToScissor)
+                    renderThreadState.scratchRect.transform(renderThreadState.strokeToViewTransform)
+                    drawAllStrokesInModifiedRegion(renderThreadState.scratchRect)
+                }
+            }
+        } else {
+            // When the contents of the previous draw call are not preserved for the next one, there
+            // is no
+            // need to do the N^2 operation of drawing every stroke into the modified region of
+            // every
+            // stroke. Instead, just draw every stroke, without any clipping to modified regions.
+            renderThreadState.scratchRect.setXBounds(
+                Float.NEGATIVE_INFINITY,
+                Float.POSITIVE_INFINITY
+            )
+            renderThreadState.scratchRect.setYBounds(
+                Float.NEGATIVE_INFINITY,
+                Float.POSITIVE_INFINITY
+            )
+            drawAllStrokesInModifiedRegion(renderThreadState.scratchRect)
+        }
+    }
+
+    private fun drawAllStrokesInModifiedRegion(modifiedRegion: MutableBox) {
+        inProgressStrokesRenderHelper.prepareToDrawInModifiedRegion(modifiedRegion)
+        // Iteration over MutableMap is guaranteed to be in insertion order, which results in proper
+        // z-order for drawing.
+        for ((strokeIdToDraw, strokeStateToDraw) in renderThreadState.toDrawStrokes) {
+            // renderThreadState.strokeStates still contains any canceled strokes so that the space
+            // they occupied can be cleared, but don't draw them again here. The canceled strokes
+            // will
+            // be removed from renderThreadState.strokeStates after drawing is finished.
+            if (renderThreadState.canceledStrokes.contains(strokeIdToDraw)) continue
+            drawStrokeState(strokeStateToDraw)
+        }
+        inProgressStrokesRenderHelper.afterDrawInModifiedRegion()
+    }
+
+    @WorkerThread
+    override fun onDrawComplete() {
+        renderThreadState.handledActions.forEach(this::handleActionAfterDraw)
+        renderThreadState.handledActions.clear()
+        threadSharedState.currentlyHandlingActions.set(false)
+    }
+
+    @WorkerThread
+    override fun reportEstimatedPixelPresentationTime(timeNanos: Long) {
+        for (latencyData in renderThreadState.latencyDatas) {
+            latencyData.estimatedPixelPresentationTime = timeNanos
+        }
+    }
+
+    @WorkerThread
+    override fun setCustomLatencyDataField(setter: (LatencyData, Long) -> Unit) {
+        val time = getNanoTime()
+        for (latencyData in renderThreadState.latencyDatas) {
+            setter(latencyData, time)
+        }
+    }
+
+    @WorkerThread
+    override fun handOffAllLatencyData() {
+        threadSharedState.finishedLatencyDatas.addAll(renderThreadState.latencyDatas)
+        renderThreadState.latencyDatas.clear()
+        postToUiThread(::handOffLatencyDataToClient)
+    }
+
+    @UiThread
+    private fun handOffLatencyDataToClient() {
+        while (!threadSharedState.finishedLatencyDatas.isEmpty()) {
+            threadSharedState.finishedLatencyDatas.poll()?.let {
+                try {
+                    latencyDataCallback.onLatencyData(it)
+                } finally {
+                    // The callback synchronously processes the LatencyData; after it returns, we
+                    // can recycle.
+                    latencyDataPool.recycle(it)
+                }
+            }
+        }
+    }
+
+    /**
+     * Fill [renderThreadState.updatedRegion] with the region that has been updated and must be
+     * redrawn, in stroke coordinates. Return `true` if and only if there is actually a region to be
+     * updated.
+     */
+    @WorkerThread
+    private fun fillUpdatedStrokeRegion(
+        strokeId: InProgressStrokeId,
+        strokeState: RenderThreadStrokeState,
+    ) {
+        if (renderThreadState.canceledStrokes.contains(strokeId)) {
+            // Any space occupied by a canceled stroke must be redrawn to clear that stroke.
+            renderThreadState.updatedRegion.reset()
+            for (coatIndex in 0 until strokeState.inProgressStroke.getBrushCoatCount()) {
+                strokeState.inProgressStroke.populateMeshBounds(
+                    coatIndex,
+                    renderThreadState.scratchEnvelope,
+                )
+                renderThreadState.updatedRegion.add(renderThreadState.scratchEnvelope)
+            }
+        } else {
+            strokeState.inProgressStroke.populateUpdatedRegion(renderThreadState.updatedRegion)
+            strokeState.inProgressStroke.resetUpdatedRegion()
+        }
+    }
+
+    /** Draw a live stroke. */
+    @WorkerThread
+    private fun drawStrokeState(strokeState: RenderThreadStrokeState) {
+        fillStrokeToViewTransform(strokeState)
+        inProgressStrokesRenderHelper.drawInModifiedRegion(
+            strokeState.inProgressStroke,
+            renderThreadState.strokeToViewTransform,
+        )
+    }
+
+    /** Calculate and update strokeToViewTransform by combining other transform matrices. */
+    @WorkerThread
+    private fun fillStrokeToViewTransform(strokeState: RenderThreadStrokeState) {
+        renderThreadState.strokeToViewTransform.set(strokeState.strokeToMotionEventTransform)
+        renderThreadState.strokeToViewTransform.postConcat(
+            renderThreadState.motionEventToViewTransform
+        )
+    }
+
+    /** Throws an error if not currently executing on the render thread. */
+    @WorkerThread
+    private fun assertOnRenderThread() {
+        inProgressStrokesRenderHelper.assertOnRenderThread()
+    }
+
+    /** An input event that can go in the (future) event queue to hand off across threads. */
+    private sealed interface InputAction
+
+    /** Represents the data passed to [startStroke]. */
+    private data class StartAction(
+        val strokeInput: StrokeInput,
+        val strokeId: InProgressStrokeId,
+        val motionEventToStrokeTransform: AndroidMatrix,
+        val brush: Brush,
+        val latencyData: LatencyData?,
+        val startEventTimeMillis: Long,
+    ) : InputAction
+
+    /**
+     * Represents the data passed to [addToStroke]. This is meant to be overwritten for recycling
+     * purposes, so it is not immutable like the less frequent start/finish actions.
+     */
+    private data class AddAction(
+        val realInputs: MutableStrokeInputBatch = MutableStrokeInputBatch(),
+        val predictedInputs: MutableStrokeInputBatch = MutableStrokeInputBatch(),
+        var strokeId: InProgressStrokeId = InProgressStrokeId.create(),
+        val realInputLatencyDatas: ArrayDeque<LatencyData> = ArrayDeque(initialCapacity = 15),
+        val predictedInputLatencyDatas: ArrayDeque<LatencyData> = ArrayDeque(initialCapacity = 15),
+    ) : InputAction
+
+    /** Represents the data passed to [finishStroke]. */
+    private data class FinishAction(
+        val strokeInput: StrokeInput?,
+        val strokeId: InProgressStrokeId,
+        val latencyData: LatencyData?,
+    ) : InputAction
+
+    /** Indicates that it's time to call [updateShape] on strokes in [dryingStrokes]. */
+    private object UpdateAction : InputAction
+
+    /** Represents the data passed to [cancelStroke]. */
+    private data class CancelAction(
+        val strokeId: InProgressStrokeId,
+        val latencyData: LatencyData
+    ) : InputAction
+
+    /** Represents an update to [motionEventToViewTransform]. */
+    private data class MotionEventToViewTransformAction(
+        val motionEventToViewTransform: AndroidMatrix
+    ) : InputAction
+
+    /**
+     * Represents a request to clear the data of a stroke cohort being handed off by
+     * [onEndOfStrokeCohortCheck].
+     */
+    private object ClearAction : InputAction
+
+    /**
+     * Represents a request to synchronize across threads, so that the UI thread can block on this
+     * operation in the action queue being reached and handled by the render thread.
+     */
+    private class FlushAction : InputAction {
+        val flushCompleted = CountDownLatch(1)
+    }
+
+    /**
+     * Represents a request to synchronize across threads, so that the UI thread can block on this
+     * operation in the action queue being reached and handled by the render thread.
+     */
+    private class SyncAction : InputAction {
+        val syncCompleted = CountDownLatch(1)
+    }
+
+    /** The result type of [claimStrokesToHandOff]. */
+    private sealed interface ClaimStrokesToHandOffResult
+
+    /**
+     * A result of [claimStrokesToHandOff] that indicates that no strokes are currently in progress,
+     * and none are finished, so inking is in an idle state.
+     */
+    private object NoneInProgressOrFinished : ClaimStrokesToHandOffResult
+
+    /**
+     * A result of [claimStrokesToHandOff] that indicates that strokes are still in progress,
+     * meaning some began with [startStroke] but haven't yet had [finishStroke] or [cancelStroke]
+     * called on them.
+     */
+    private object StillInProgress : ClaimStrokesToHandOffResult
+
+    /**
+     * A result of [claimStrokesToHandOff] that indicates that no strokes are currently in progress,
+     * but debouncing is currently preventing handoff.
+     */
+    private object NoneInProgressButDebouncing : ClaimStrokesToHandOffResult
+
+    /**
+     * A result of [claimStrokesToHandOff] that indicates that no strokes are currently in progress,
+     * but [setPauseStrokeCohortHandoffs] is currently preventing handoff.
+     */
+    private object NoneInProgressButHandoffsPaused : ClaimStrokesToHandOffResult
+
+    /**
+     * A result of [claimStrokesToHandOff] that indicates that no strokes are currently in progress,
+     * and nothing else is preventing handoff of the provided strokes.
+     *
+     * @param finishedStrokes The finished strokes, which cannot be empty.
+     */
+    private data class Finished(
+        @Size(min = 1) val finishedStrokes: Map<InProgressStrokeId, FinishedStroke>
+    ) : ClaimStrokesToHandOffResult {
+        init {
+            require(finishedStrokes.isNotEmpty())
+        }
+    }
+
+    /** Holds the state for a given stroke, as needed by the render thread. */
+    private data class RenderThreadStrokeState(
+        val inProgressStroke: InProgressStroke,
+        val strokeToMotionEventTransform: AndroidMatrix,
+        val startEventTimeMillis: Long,
+    )
+
+    /** Holds the state for a given stroke, as needed by the UI thread. */
+    private data class UiStrokeState(
+        val motionEventToStrokeTransform: AndroidMatrix,
+        val startEventTimeMillis: Long,
+        val strokeUnitLengthCm: Float,
+    )
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesRenderHelper.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesRenderHelper.kt
new file mode 100644
index 0000000..ec1b29d
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/InProgressStrokesRenderHelper.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import android.graphics.Path
+import androidx.annotation.UiThread
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import androidx.ink.authoring.latency.LatencyData
+import androidx.ink.geometry.MutableBox
+import androidx.ink.strokes.InProgressStroke
+
+/**
+ * Manages rendering of in-progress strokes and the synchronized handoff of strokes from being in
+ * progress to finished. Different implementations of this interface may make use of different
+ * approaches to minimize the latency of drawn strokes appearing on screen, or different approaches
+ * to synchronizing the handoff. This is an internal utility used by [InProgressStrokesManager], it
+ * is not to be used by clients of the Ink Strokes API.
+ *
+ * Terminology:
+ * - UI thread: The Android main thread, which HWUI (View) operations take place on.
+ * - Render thread: The single thread, managed by an implementation of this interface, which is used
+ *   for drawing operations. In some cases this may be the UI thread itself, but it usually is not.
+ * - Main View: The root View of [InProgressStrokesManager] which clients add to their View
+ *   hierarchy. Its parent belongs to the client, and an implementation of this interface may add
+ *   children to it and manage them.
+ * - Stroke cohort: A group of strokes that are in progress at the same time, which means that they
+ *   need to be handed off to HWUI rendering at the same time.
+ */
+@OptIn(ExperimentalLatencyDataApi::class)
+internal interface InProgressStrokesRenderHelper {
+
+    /**
+     * Whether stroke contents that were drawn earlier are preserved for later draws, as an
+     * optimization to redraw only the modified regions of the screen.
+     */
+    val contentsPreservedBetweenDraws: Boolean
+
+    /**
+     * Whether [InProgressStrokesView.handoffDebounceTimeMs] is supported. If not, then handoff
+     * should be initiated as soon as possible.
+     */
+    val supportsDebounce: Boolean
+
+    /**
+     * Whether [InProgressStrokesView.flush] is supported. If not, then that method will not be able
+     * to wait for strokes to be completed.
+     */
+    val supportsFlush: Boolean
+
+    /**
+     * An area of the inking surface where no ink should be visible, and the contents beneath should
+     * show through.
+     */
+    var maskPath: Path?
+
+    /**
+     * Can be used by [InProgressStrokesManager] to fail fast when not operating on the expected
+     * thread.
+     */
+    fun assertOnRenderThread()
+
+    /**
+     * Called by [InProgressStrokesManager] when new content must be drawn. Will lead to
+     * [InProgressStrokesRenderHelper.onDraw].
+     */
+    @UiThread fun requestDraw()
+
+    /**
+     * Allows communication between this interface and the code making use of it, which is presumed
+     * to be an [InProgressStrokesManager].
+     */
+    interface Callback {
+
+        /**
+         * Called on the render thread to prompt [InProgressStrokesManager] to start making draw
+         * calls coordinate the overall logic for making draw calls to the renderer. An
+         * implementation of [InProgressStrokesRenderHelper] may save some context state before
+         * calling this and reset it afterwards.
+         */
+        fun onDraw()
+
+        /**
+         * Called on the render thread to allow [InProgressStrokesManager] to perform some cleanup
+         * logic after the draw calls are complete.
+         */
+        fun onDrawComplete()
+
+        /**
+         * For latency tracking. Called on the render thread to report the estimated time when
+         * newly-rendered pixels will be visible to the user. This time will probably be in the near
+         * future.
+         *
+         * @param timeNanos Estimated nanosecond presentation timestamp, in the [System.nanoTime]
+         *   base.
+         */
+        fun reportEstimatedPixelPresentationTime(timeNanos: Long)
+
+        /**
+         * For latency tracking. Called on the render thread to set a client-chosen field in every
+         * in-flight [LatencyData] instance. The [setter] will be run once for each [LatencyData]
+         * that is currently active. The second argument will be the value of [System.nanoTime] when
+         * this callback was invoked. This callback may be used, for example, to set fields that are
+         * specific to a particular implementation of [InProgressStrokesRenderHelper].
+         */
+        fun setCustomLatencyDataField(setter: (LatencyData, Long) -> Unit)
+
+        /**
+         * For latency tracking. Called on the render thread to finalize all in-flight [LatencyData]
+         * instances and report them to the Latency API client.
+         */
+        fun handOffAllLatencyData()
+
+        /**
+         * Called on the UI thread to either disallow (pause) or allow (unpause) calls to
+         * [requestStrokeCohortHandoffToHwui].
+         */
+        @UiThread fun setPauseStrokeCohortHandoffs(paused: Boolean)
+
+        /**
+         * Called to hand off a group of finished strokes from being rendered internally to being
+         * rendered by a higher level in HWUI. This must happen synchronously, in the same HWUI
+         * frame. Failure to do so will result in a flicker on handoff, where the stroke is
+         * temporarily not rendered. Initiated by [requestStrokeCohortHandoffToHwui].
+         */
+        @UiThread
+        fun onStrokeCohortHandoffToHwui(strokeCohort: Map<InProgressStrokeId, FinishedStroke>)
+
+        /**
+         * Called some time after [onStrokeCohortHandoffToHwui], when it is appropriate to start
+         * calling [requestDraw] again.
+         */
+        @UiThread fun onStrokeCohortHandoffToHwuiComplete()
+    }
+
+    /**
+     * Set up rendering to a particular region that has modified geometry. Called on the render
+     * thread.
+     */
+    fun prepareToDrawInModifiedRegion(modifiedRegionInMainView: MutableBox)
+
+    /**
+     * Draw an [InProgressStroke] in the region previously prepared with
+     * [prepareToDrawInModifiedRegion]. This may be called multiple times per modified region with
+     * different [InProgressStroke] objects. Called on the render thread.
+     */
+    fun drawInModifiedRegion(inProgressStroke: InProgressStroke, strokeToMainViewTransform: Matrix)
+
+    /**
+     * Cleans up what was initialized in [prepareToDrawInModifiedRegion]. Called on the render
+     * thread.
+     */
+    fun afterDrawInModifiedRegion()
+
+    /**
+     * Clear all contents that have previously been rendered, if [contentsPreservedBetweenDraws] is
+     * `true`. Called on the render thread.
+     */
+    fun clear()
+
+    /**
+     * Called by [InProgressStrokesManager] when no new content is expected and the current
+     * in-progress content should be handed off to be rendered by HWUI. HWUI rendering of finished
+     * strokes is not handled by this class - this will lead to
+     * [Callback.onStrokeCohortHandoffToHwui], which is responsible for initiating HWUI rendering.
+     * Between this and [Callback.onStrokeCohortHandoffToHwuiComplete], any calls to [requestDraw]
+     * may not (and may never become) visible.
+     */
+    @UiThread
+    fun requestStrokeCohortHandoffToHwui(handingOff: Map<InProgressStrokeId, FinishedStroke>)
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/MutableBoxTransform.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/MutableBoxTransform.kt
new file mode 100644
index 0000000..aca2a27
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/MutableBoxTransform.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import androidx.ink.geometry.ImmutableVec
+import androidx.ink.geometry.MutableBox
+
+/** Used for temporary calculations. */
+private val scratchPoints by threadLocal { FloatArray(8) }
+
+/**
+ * Apply a [Matrix] transform to this [MutableBox] and save the result in [destination] (which can
+ * be this [MutableBox]). Because [MutableBox] is axis-aligned, this transform may grow the
+ * transformed region if the transform involved rotation, such that the entire transformed rectangle
+ * fits inside of the result.
+ */
+internal fun MutableBox.transform(transform: Matrix, destination: MutableBox = this) {
+    // Set scratchPoints to the 4 corners of the source rect, alternating between x and y.
+    // (min, min)
+    scratchPoints[0] = xMin
+    scratchPoints[1] = yMin
+    // (min, max)
+    scratchPoints[2] = xMin
+    scratchPoints[3] = yMax
+    // (max, min)
+    scratchPoints[4] = xMax
+    scratchPoints[5] = yMin
+    // (max, max)
+    scratchPoints[6] = xMax
+    scratchPoints[7] = yMax
+
+    // Apply the transform to scratchPoints, updating it in place.
+    transform.mapPoints(scratchPoints)
+
+    var newXMin = scratchPoints[0]
+    var newYMin = scratchPoints[1]
+    var newXMax = scratchPoints[0]
+    var newYMax = scratchPoints[1]
+    for (xIndex in 2..6 step 2) scratchPoints[xIndex].let {
+        when {
+            it < newXMin -> newXMin = it
+            it > newXMax -> newXMax = it
+        }
+    }
+    for (yIndex in 3..7 step 2) scratchPoints[yIndex].let {
+        when {
+            it < newYMin -> newYMin = it
+            it > newYMax -> newYMax = it
+        }
+    }
+    destination.populateFromTwoPoints(
+        ImmutableVec(newXMin, newYMin),
+        ImmutableVec(newXMax, newYMax)
+    )
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/StrokeInputPool.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/StrokeInputPool.kt
new file mode 100644
index 0000000..b031e77
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/StrokeInputPool.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.graphics.Matrix
+import android.view.MotionEvent
+import androidx.annotation.IntRange
+import androidx.annotation.UiThread
+import androidx.ink.brush.InputToolType
+import androidx.ink.strokes.MutableStrokeInputBatch
+import androidx.ink.strokes.StrokeInput
+import java.util.concurrent.ConcurrentLinkedQueue
+
+/**
+ * Helps manage [StrokeInput] objects in an efficient way, including reusing and recycling instances
+ * to avoid allocating an instance each time one is needed.
+ *
+ * This class includes functionality to populate [StrokeInput] instances from [MotionEvent] objects.
+ * This technique of populating a separate piece of data from a [MotionEvent] is required to pass
+ * the input data to another thread, because a [MotionEvent] can only be used on the UI thread:
+ * - That is where [MotionEvent] objects are delivered, via an `onTouch` method.
+ * - The [MotionEvent] is considered invalid after `onTouch` returns, because its instance will be
+ *   recycled by the OS.
+ * - It is invalid to hand a [MotionEvent] off to another thread without blocking `onTouch` from
+ *   returning.
+ * - `onTouch` runs on the UI thread, and it is strongly discouraged to block the UI thread.
+ *
+ * @param preAllocatedInstances The number of [StrokeInput] instances to be allocated and added to
+ *   the recycling pool immediately upon creation. If this value is slightly larger than the number
+ *   of instances that will ever be needed simultaneously, then the allocation risk (with potential
+ *   garbage collection penalty) can be paid once up front instead of possibly later.
+ */
+internal class StrokeInputPool(preAllocatedInstances: Int = 15) {
+
+    private val pool =
+        ConcurrentLinkedQueue<StrokeInput>().apply {
+            for (i in 0 until preAllocatedInstances) add(StrokeInput())
+        }
+
+    /** Only usable by the UI thread for [MotionEvent] calculations. */
+    private val scratchPoint = FloatArray(2)
+
+    /**
+     * Get a [StrokeInput] instance, hopefully (but not necessarily) one that was already allocated
+     * and recycled, and fill it with the given values. When the caller is done using it, the
+     * [StrokeInput] should be passed to [recycle] to ensure it can be reused by a future call to
+     * [obtain] so that the future call should not need to allocate a new instance.
+     *
+     * See [StrokeInput.update] for a description of each argument.
+     */
+    fun obtain(
+        x: Float,
+        y: Float,
+        @IntRange(from = 0L) elapsedTimeMillis: Long,
+        toolType: InputToolType = InputToolType.UNKNOWN,
+        strokeUnitLengthCm: Float = StrokeInput.NO_STROKE_UNIT_LENGTH,
+        pressure: Float = StrokeInput.NO_PRESSURE,
+        tiltRadians: Float = StrokeInput.NO_TILT,
+        orientationRadians: Float = StrokeInput.NO_ORIENTATION,
+    ): StrokeInput {
+        return (pool.poll() ?: StrokeInput()).apply {
+            update(
+                x = x,
+                y = y,
+                elapsedTimeMillis = elapsedTimeMillis,
+                toolType = toolType,
+                strokeUnitLengthCm = strokeUnitLengthCm,
+                pressure = pressure,
+                tiltRadians = tiltRadians,
+                orientationRadians = orientationRadians,
+            )
+        }
+    }
+
+    /**
+     * Allow the given [StrokeInput] to be reused by a future call to [obtain]. It is illegal to
+     * access this instance of [StrokeInput] after this call until it is made available for reuse
+     * through [obtain].
+     */
+    fun recycle(strokeInput: StrokeInput) {
+        pool.offer(strokeInput)
+    }
+
+    /**
+     * Get a [StrokeInput] instance, hopefully (but not necessarily) one that was already allocated
+     * and recycled, which contains the most recent (non-historical) input data from the given
+     * [MotionEvent] at the given [pointerIndex]. This is often useful for down and up events, where
+     * historical data doesn't tend to exist (or wouldn't make much sense if it did). For move
+     * events, where it is useful to have the full history, see [obtainAllHistoryForMotionEvent].
+     *
+     * This function must be called on the UI thread, but its results can be passed to another
+     * thread.
+     *
+     * @param event The [MotionEvent] containing the desired data.
+     * @param pointerIndex The index (not ID!) of the pointer within [event] to obtain data from.
+     * @param motionEventToStrokeTransform A [Matrix] that transforms the `x` and `y` position
+     *   coordinates of [event] into the client-defined stroke coordinate system.
+     * @param strokeStartTimeMillis The time at which the stroke started in the
+     *   [android.os.SystemClock.elapsedRealtime] time base.
+     * @return A [StrokeInput] instance that is populated with the appropriate input data.
+     */
+    @UiThread
+    fun obtainSingleValueForMotionEvent(
+        event: MotionEvent,
+        pointerIndex: Int,
+        motionEventToStrokeTransform: Matrix,
+        strokeStartTimeMillis: Long,
+        strokeUnitLengthCm: Float = StrokeInput.NO_STROKE_UNIT_LENGTH,
+    ): StrokeInput {
+        return obtainHistoricalValueForMotionEvent(
+            event,
+            pointerIndex,
+            // `historySize` is a special value for `historyIndex` indicating the non-historical
+            // input
+            // point in this event.
+            event.historySize,
+            motionEventToStrokeTransform,
+            strokeStartTimeMillis,
+            strokeUnitLengthCm,
+        )
+    }
+
+    /**
+     * Get multiple [StrokeInput] instances added to the given [outBatch], containing all the input
+     * data (both the most recent/non-historical data and the historical data) from the given
+     * [MotionEvent] at the given [pointerIndex]. This is often useful for move events, where
+     * historical data is expected and fill in data at a more desirable granularity. For down and up
+     * events, where historical data isn't as applicable, see [obtainSingleValueForMotionEvent].
+     * Note that this will produce [MotionEvent.getHistorySize] + 1 [StrokeInput] values, where the
+     * final value is the non-historical (primary) value on the given [MotionEvent].
+     *
+     * This function must be called on the UI thread, but its results can be passed to another
+     * thread.
+     *
+     * @param event The [MotionEvent] containing the desired data.
+     * @param pointerIndex The index (not ID!) of the pointer within [event] to obtain data from.
+     * @param motionEventToStrokeTransform A [Matrix] that transforms the `x` and `y` position
+     *   coordinates of [event] into the client-defined stroke coordinate system.
+     * @param strokeStartTimeMillis The time at which the stroke started in the
+     *   [android.os.SystemClock.elapsedRealtime] time base.
+     * @param outBatch The [StrokeInputBatch.Builder] that will contain the produced result values.
+     *   Any existing data in here will be lost.
+     */
+    @UiThread
+    fun obtainAllHistoryForMotionEvent(
+        event: MotionEvent,
+        pointerIndex: Int,
+        motionEventToStrokeTransform: Matrix,
+        strokeStartTimeMillis: Long,
+        strokeUnitLengthCm: Float = StrokeInput.NO_STROKE_UNIT_LENGTH,
+        outBatch: MutableStrokeInputBatch,
+    ) {
+        // This does not trim the capacity of the list, so if it was pre-allocated to a big enough
+        // size
+        // then adding to it would not require any allocations for resizing.
+        outBatch.clear()
+        // Include `historySize` in this loop to represent the non-historical input point in this
+        // event.
+        for (historyIndex in 0..event.historySize) {
+            val input =
+                obtainHistoricalValueForMotionEvent(
+                    event,
+                    pointerIndex,
+                    historyIndex,
+                    motionEventToStrokeTransform,
+                    strokeStartTimeMillis,
+                    strokeUnitLengthCm,
+                )
+            try {
+                outBatch.addOrIgnore(input)
+            } finally {
+                recycle(input)
+            }
+        }
+    }
+
+    /**
+     * A convenience function to [recycle] all the inputs in a [Collection]. Often called after the
+     * data from [obtainAllHistoryForMotionEvent] has been used, which may be on a thread that is
+     * not the UI thread.
+     */
+    fun recycleAll(strokeInputs: Collection<StrokeInput>) {
+        strokeInputs.forEach(::recycle)
+    }
+
+    /**
+     * See [obtainAllHistoryForMotionEvent]. Gets a [StrokeInput] populated with the input data from
+     * the given [MotionEvent] at the given [pointerIndex] for a given [historyIndex]. The
+     * [historyIndex] is between `0` and `event.historySize`, with the special value
+     * `event.historySize` representing the most recent (non-historical) input data in this event.
+     *
+     * This function must be called on the UI thread, but its results can be passed to another
+     * thread.
+     */
+    @UiThread
+    private fun obtainHistoricalValueForMotionEvent(
+        event: MotionEvent,
+        pointerIndex: Int,
+        historyIndex: Int,
+        motionEventToStrokeTransform: Matrix,
+        strokeStartTimeMillis: Long,
+        strokeUnitLengthCm: Float,
+    ): StrokeInput {
+        scratchPoint[0] =
+            event.getMaybeHistoricalAxisValue(MotionEvent.AXIS_X, pointerIndex, historyIndex)
+        scratchPoint[1] =
+            event.getMaybeHistoricalAxisValue(MotionEvent.AXIS_Y, pointerIndex, historyIndex)
+        // Modify `scratchPoint` in place.
+        motionEventToStrokeTransform.mapPoints(scratchPoint)
+        return obtain(
+            x = scratchPoint[0],
+            y = scratchPoint[1],
+            elapsedTimeMillis =
+                (event.getMaybeHistoricalEventTimeMillis(historyIndex) - strokeStartTimeMillis),
+            toolType = getToolTypeFromMotionEvent(event, pointerIndex),
+            strokeUnitLengthCm = strokeUnitLengthCm,
+            pressure =
+                if (event.getToolType(pointerIndex) == MotionEvent.TOOL_TYPE_STYLUS) {
+                    event
+                        .getMaybeHistoricalAxisValue(
+                            MotionEvent.AXIS_PRESSURE,
+                            pointerIndex,
+                            historyIndex
+                        )
+                        .coerceIn(0f, 1f)
+                } else {
+                    StrokeInput.NO_PRESSURE
+                },
+            tiltRadians =
+                if (event.getToolType(pointerIndex) == MotionEvent.TOOL_TYPE_STYLUS) {
+                    event
+                        .getMaybeHistoricalAxisValue(
+                            MotionEvent.AXIS_TILT,
+                            pointerIndex,
+                            historyIndex
+                        )
+                        .coerceIn(0f, Math.PI.toFloat() / 2F)
+                } else {
+                    StrokeInput.NO_TILT
+                },
+            orientationRadians =
+                convertOrientationToStrokeInputRadians(
+                    event.getToolType(pointerIndex),
+                    event.getMaybeHistoricalAxisValue(
+                        MotionEvent.AXIS_ORIENTATION,
+                        pointerIndex,
+                        historyIndex,
+                    ),
+                ),
+        )
+    }
+
+    /** Map the [MotionEvent] tool type into a [StrokeInput] tool type. */
+    private fun getToolTypeFromMotionEvent(
+        motionEvent: MotionEvent,
+        pointerIndex: Int,
+    ): InputToolType {
+        return when (motionEvent.getToolType(pointerIndex)) {
+            MotionEvent.TOOL_TYPE_MOUSE -> InputToolType.MOUSE
+            MotionEvent.TOOL_TYPE_STYLUS,
+            MotionEvent.TOOL_TYPE_ERASER -> InputToolType.STYLUS
+            else -> InputToolType.TOUCH
+        }
+    }
+
+    /**
+     * Gets the axis value of a historical event - one that was previously unreported, was batched
+     * into this [MotionEvent], but isn't the primary (most recent) event in this [MotionEvent].
+     * Normally [MotionEvent.getHistorySize] would be an invalid argument for [historyIndex], as the
+     * history index is zero-based, but for convenience we treat it as a special value to get the
+     * time of the primary (most recent) event in this [MotionEvent].
+     *
+     * See [MotionEvent.getHistoricalAxisValue].
+     */
+    private fun MotionEvent.getMaybeHistoricalAxisValue(
+        axis: Int,
+        pointerIndex: Int,
+        historyIndex: Int,
+    ): Float {
+        return if (historyIndex == historySize) {
+            getAxisValue(axis, pointerIndex)
+        } else {
+            getHistoricalAxisValue(axis, pointerIndex, historyIndex)
+        }
+    }
+
+    /**
+     * Gets the time in milliseconds of a historical event - one that was previously unreported, was
+     * batched into this [MotionEvent], but isn't the primary (most recent) event in this
+     * [MotionEvent]. Normally [MotionEvent.getHistorySize] would be an invalid argument for
+     * [historyIndex], as the history index is zero-based, but for convenience we treat it as a
+     * special value to get the time of the primary (most recent) event in this [MotionEvent].
+     *
+     * See [MotionEvent.getHistoricalEventTime].
+     */
+    private fun MotionEvent.getMaybeHistoricalEventTimeMillis(historyIndex: Int): Long {
+        return if (historyIndex == historySize) {
+            eventTime
+        } else {
+            getHistoricalEventTime(historyIndex)
+        }
+    }
+
+    /**
+     * Convert an orientation angle from how [MotionEvent] reports it to how [StrokeInput] expects
+     * it.
+     */
+    private fun convertOrientationToStrokeInputRadians(toolType: Int, orientation: Float): Float {
+        if (toolType == MotionEvent.TOOL_TYPE_STYLUS) {
+            // Convert MotionEvent orientation angles into StrokeInput orientation angles.
+            // MotionEvent orientation values lie in [-PI, PI] with zero where the tip of the stylus
+            // is
+            // pointing "up" (think the tool bar), positive values are the tip pointing "right" and
+            // the
+            // negative values are the tip pointing "left".
+            // StrokeInput orientationRadians values lie in [0, 2PI] with zero being where the tip
+            // points
+            // to the "left" and increases as you rotate clockwise (towards "up", and so on).
+            return (orientation + 2.5f * Math.PI.toFloat()).mod(2 * Math.PI.toFloat())
+        }
+        return StrokeInput.NO_ORIENTATION
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/ThreadLocalDelegate.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/ThreadLocalDelegate.kt
new file mode 100644
index 0000000..e0badf3
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/ThreadLocalDelegate.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import kotlin.reflect.KProperty
+
+/**
+ * [ThreadLocal] subclass that can be used as a read-only delegate with the `by` operator.
+ *
+ * Example:
+ * ```
+ * val foo by threadLocal { MutableVec(...) }
+ * foo.x = 5F
+ * foo.y = 6F
+ * ```
+ */
+internal fun <T> threadLocal(initialValueProvider: () -> T): ThreadLocalDelegate<T> =
+    ThreadLocalDelegate(initialValueProvider)
+
+internal class ThreadLocalDelegate<T> constructor(private val initialValueProvider: () -> T) :
+    ThreadLocal<T>() {
+    override fun initialValue(): T = initialValueProvider()
+
+    @Suppress("NOTHING_TO_INLINE")
+    public inline operator fun getValue(thisObj: Any?, property: KProperty<*>): T = get()!!
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/WindowFinder.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/WindowFinder.kt
new file mode 100644
index 0000000..2e9ea9a
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/internal/WindowFinder.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.internal
+
+import android.app.Activity
+import android.content.ContextWrapper
+import android.view.View
+import android.view.Window
+import androidx.annotation.UiThread
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import java.lang.IllegalStateException
+
+internal object WindowFinder {
+
+    @UiThread
+    fun findWindow(view: View): Window? {
+        // Unwrap the ContextWrapper hierarchy to see if its root is an Activity.
+        val rootContext = run {
+            var current = view.context
+            while (current !is Activity && current is ContextWrapper) current = current.baseContext
+            current
+        }
+        if (rootContext is Activity) return rootContext.window
+
+        // If the View is hosted inside a Fragment, it may have a wrapped Context which does not
+        // necessarily have an Activity as a direct ancestor. However, the Activity can be accessed
+        // through the Fragment itself.
+        val fragment =
+            try {
+                // There is no version of this function that returns null when no Fragment is found
+                // for the
+                // view, it only throws in that case, so wrap it in a try-catch.
+                FragmentManager.findFragment<Fragment>(view)
+            } catch (ex: IllegalStateException) {
+                // Normally it's a bad idea to catch a RuntimeException like this - do not imitate!
+                null
+            }
+        return fragment?.activity?.window
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyData.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyData.kt
new file mode 100644
index 0000000..58e173e
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyData.kt
@@ -0,0 +1,271 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import android.util.Log
+import android.view.MotionEvent
+import androidx.annotation.RestrictTo
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+
+/**
+ * Timestamps for signpost moments in the processing of a single input event. This structure is for
+ * measuring and reporting the latency in [InProgressStrokesView] and its various helper classes.
+ * Timestamps are in the [System.nanoTime] timebase, which is nanoseconds since system boot, except
+ * for deep sleep time.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+@ExperimentalLatencyDataApi
+public class LatencyData {
+
+    /**
+     * The type of input event being tracked. See [strokeAction] for a potentially more relevant
+     * alternative.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var eventAction: EventAction = EventAction.UNKNOWN
+
+    /**
+     * The type of stroke action being tracked. This often aligns with [eventAction], but since
+     * clients can use any [MotionEvent] for any stroke action, this may be different.
+     * [strokeAction] is often much more relevant to track as a dimension affecting performance
+     * compared to [eventAction].
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var strokeAction: StrokeAction = StrokeAction.UNKNOWN
+
+    /**
+     * The ID of the stroke being tracked. For a given [strokeId], there should be a stream of
+     * [LatencyData] events with a [strokeAction] pattern of a [StrokeAction.START], zero or more
+     * [StrokeAction.ADD] and [StrokeAction.PREDICTED_ADD], and either a [StrokeAction.FINISH] or a
+     * [StrokeAction.CANCEL].
+     *
+     * Note that this ID has no meaning outside of the current app session, so it is not meant to be
+     * logged directly. It can be used as part of client-side aggregation logic to associate
+     * [LatencyData] events with one another.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var strokeId: InProgressStrokeId = UNKNOWN_STROKE_ID
+
+    /**
+     * The number of input events, including both the "primary" (most recent) event and any
+     * "historical" events batched with it, in the `MotionEvent` whose processing is described by
+     * this [LatencyData]. A value of 1 means that there were no historical inputs.
+     *
+     * Note that this value is 1 more than `MotionEvent.getHistorySize()`.
+     *
+     * MOVE and PREDICTED_MOVE [EventAction]s are batched separately by Android, so the [batchSize]
+     * for real and predicted inputs received at the same time are independent.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public var batchSize: Int = Int.MIN_VALUE
+
+    /**
+     * The index of this input event in the batch: 0 <= [batchIndex] < [batchSize]. Index 0
+     * signifies the earliest event; `batchSize-1` signifies the most recent event.
+     *
+     * It is not guaranteed that every index in the batch will be present in a [LatencyData]. START
+     * and FINISH [StrokeAction]s may originate from batched MOVE [EventAction]s, in which case only
+     * the primary input is used. In addition, input sanitization may drop input points.
+     *
+     * MOVE and PREDICTED_MOVE [EventAction]s are batched separately by Android, so the [batchIndex]
+     * for real and predicted inputs received at the same time are independent.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public var batchIndex: Int = Int.MIN_VALUE
+
+    /**
+     * Nanosecond timestamp of when the low-level input driver recorded the user input. For
+     * predicted inputs, this is a future timestamp, so it may be later than other signpost times.
+     * For Android U+ (API 34+), this value is just [MotionEvent.getEventTimeNanos]. On earlier
+     * Android versions, only millisecond precision is available, and therefore any calculations
+     * based on this value should be considered to have only millisecond accuracy.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public var osDetectsEvent: Long = Long.MIN_VALUE
+
+    public val isOsDetectsEventSet: Boolean
+        get() = osDetectsEvent != Long.MIN_VALUE
+
+    /**
+     * Nanosecond timestamp of the start of the call to [InProgressStrokesView.startStroke],
+     * [InProgressStrokesView.addToStroke], [InProgressStrokesView.finishStroke], or
+     * [InProgressStrokesView.cancelStroke].
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var strokesViewGetsAction: Long = Long.MIN_VALUE
+
+    public val isStrokesViewGetsActionSet: Boolean
+        get() = strokesViewGetsAction != Long.MIN_VALUE
+
+    /**
+     * Nanosecond timestamp of when [InProgressStrokesView] finishes all geometry generation and
+     * renderer draw calls.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var strokesViewFinishesDrawCalls: Long = Long.MIN_VALUE
+
+    /**
+     * Estimated nanosecond timestamp of when the newly-drawn pixels will become visible to the
+     * user. The nature of this estimate depends on the graphics backend, but this field is meant to
+     * be comparable across graphics backends.
+     */
+    @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public var estimatedPixelPresentationTime: Long = Long.MIN_VALUE
+
+    public val canvasFrontBufferStrokesRenderHelperData: CanvasFrontBufferStrokesRenderHelperData =
+        CanvasFrontBufferStrokesRenderHelperData()
+
+    /** Fields specific to [CanvasInProgressStrokesRenderHelperV29]. */
+    public class CanvasFrontBufferStrokesRenderHelperData {
+        /**
+         * Nanosecond timestamp of when the render helper finishes draw calls to the front-buffered
+         * layer. The updated appearance will be visible once the front-buffered layer is submitted
+         * to the Hardware Composer, all layers are composited together, and the display scan
+         * finishes.
+         */
+        @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public var finishesDrawCalls: Long = Long.MIN_VALUE
+
+        public val isFinishesDrawCallsSet: Boolean
+            get() = finishesDrawCalls != Long.MIN_VALUE
+
+        public override fun toString(): String {
+            return "CanvasFrontBufferStrokesRenderHelperData(finishesDrawCalls=$finishesDrawCalls)"
+        }
+    }
+
+    public val hwuiInProgressStrokesRenderHelperData: HwuiInProgressStrokesRenderHelperData =
+        HwuiInProgressStrokesRenderHelperData()
+
+    /** Fields specific to [CanvasInProgressStrokesRenderHelperV21]. */
+    public class HwuiInProgressStrokesRenderHelperData {
+        /**
+         * Nanosecond timestamp of when the render helper finishes draw calls to the
+         * [android.view.View]. The updated appearance will be visible in the next animation frame.
+         */
+        @set:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public var finishesDrawCalls: Long = Long.MIN_VALUE
+
+        public override fun toString(): String {
+            return "HwuiInProgressStrokesRenderHelperData(finishesDrawCalls=$finishesDrawCalls)"
+        }
+    }
+
+    init {
+        reset()
+    }
+
+    /** Resets all fields to their default values. */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public fun reset() {
+        strokeAction = StrokeAction.UNKNOWN
+        eventAction = EventAction.UNKNOWN
+        strokeId = UNKNOWN_STROKE_ID
+        batchSize = Int.MIN_VALUE
+        batchIndex = Int.MIN_VALUE
+        osDetectsEvent = Long.MIN_VALUE
+        strokesViewGetsAction = Long.MIN_VALUE
+        strokesViewFinishesDrawCalls = Long.MIN_VALUE
+        estimatedPixelPresentationTime = Long.MIN_VALUE
+        canvasFrontBufferStrokesRenderHelperData.finishesDrawCalls = Long.MIN_VALUE
+        hwuiInProgressStrokesRenderHelperData.finishesDrawCalls = Long.MIN_VALUE
+    }
+
+    public override fun toString(): String {
+        return "LatencyData(" +
+            "strokeAction=$strokeAction, " +
+            "eventAction=$eventAction, " +
+            "strokeId=$strokeId, " +
+            "batchSize=$batchSize, " +
+            "batchIndex=$batchIndex, " +
+            "osDetectsEvent=$osDetectsEvent, " +
+            "strokesViewGetsAction=$strokesViewGetsAction, " +
+            "strokesViewFinishesDrawCalls=$strokesViewFinishesDrawCalls, " +
+            "estimatedPixelPresentationTime=$estimatedPixelPresentationTime, " +
+            "canvasFrontBufferStrokesRenderHelperData=$canvasFrontBufferStrokesRenderHelperData, " +
+            "hwuiInProgressStrokesRenderHelperData=$hwuiInProgressStrokesRenderHelperData" +
+            ")"
+    }
+
+    public class StrokeAction private constructor() {
+        public override fun toString(): String =
+            when (this) {
+                UNKNOWN -> "LatencyData.StrokeAction.UNKNOWN"
+                START -> "LatencyData.StrokeAction.START"
+                ADD -> "LatencyData.StrokeAction.ADD"
+                PREDICTED_ADD -> "LatencyData.StrokeAction.PREDICTED_ADD"
+                FINISH -> "LatencyData.StrokeAction.FINISH"
+                CANCEL -> "LatencyData.StrokeAction.CANCEL"
+                else -> throw IllegalStateException("Unrecognized StrokeAction: $this")
+            }
+
+        public companion object {
+            public val UNKNOWN: StrokeAction = StrokeAction()
+            public val START: StrokeAction = StrokeAction()
+            public val ADD: StrokeAction = StrokeAction()
+            public val PREDICTED_ADD: StrokeAction = StrokeAction()
+            public val FINISH: StrokeAction = StrokeAction()
+            public val CANCEL: StrokeAction = StrokeAction()
+        }
+    }
+
+    public class EventAction private constructor() {
+        public override fun toString(): String =
+            when (this) {
+                UNKNOWN -> "LatencyData.EventAction.UNKNOWN"
+                DOWN -> "LatencyData.EventAction.DOWN"
+                MOVE -> "LatencyData.EventAction.MOVE"
+                PREDICTED_MOVE -> "LatencyData.EventAction.PREDICTED_MOVE"
+                UP -> "LatencyData.EventAction.UP"
+                CANCEL -> "LatencyData.EventAction.CANCEL"
+                // The else case is impossible because each instance is a singleton.
+                else -> throw IllegalStateException("Unknown EventAction $this")
+            }
+
+        public companion object {
+            // Identity of these singleton constants comes from their addresses alone.
+            public val UNKNOWN: EventAction = EventAction()
+            public val DOWN: EventAction = EventAction()
+            public val MOVE: EventAction = EventAction()
+            public val PREDICTED_MOVE: EventAction = EventAction()
+            public val UP: EventAction = EventAction()
+            public val CANCEL: EventAction = EventAction()
+
+            public fun fromMotionEvent(
+                event: MotionEvent,
+                predicted: Boolean = false
+            ): EventAction =
+                when (event.actionMasked) {
+                    MotionEvent.ACTION_DOWN,
+                    MotionEvent.ACTION_POINTER_DOWN -> DOWN
+                    MotionEvent.ACTION_MOVE -> if (predicted) PREDICTED_MOVE else MOVE
+                    MotionEvent.ACTION_UP,
+                    MotionEvent.ACTION_POINTER_UP -> UP
+                    MotionEvent.ACTION_CANCEL -> CANCEL
+                    else ->
+                        EventAction.UNKNOWN.also {
+                            Log.e(
+                                "LatencyData.EventAction",
+                                "Unknown MotionEvent.actionMasked ${event.actionMasked}",
+                            )
+                        }
+                }
+        }
+    }
+
+    public companion object {
+        public val UNKNOWN_STROKE_ID: InProgressStrokeId = InProgressStrokeId.create()
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataCallback.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataCallback.kt
new file mode 100644
index 0000000..19ffefa
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataCallback.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.UiThread
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+@ExperimentalLatencyDataApi
+public fun interface LatencyDataCallback {
+    /** A callback invoked once per input event to send [LatencyData] to a client. */
+    @UiThread public fun onLatencyData(latency: LatencyData): Unit
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataPool.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataPool.kt
new file mode 100644
index 0000000..5864326
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/latency/LatencyDataPool.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.latency
+
+import android.os.Build
+import android.util.Log
+import android.view.MotionEvent
+import androidx.annotation.UiThread
+import androidx.ink.authoring.ExperimentalLatencyDataApi
+import androidx.ink.authoring.InProgressStrokeId
+import kotlin.collections.ArrayDeque
+import kotlin.collections.MutableCollection
+
+/**
+ * A pool of preallocated [LatencyData]s to be recycled.
+ *
+ * The purpose of this class is to preallocate all [LatencyData] instances that the caller will ever
+ * need, in order to avoid new allocations during latency-sensitive interactive use of the client
+ * app.
+ */
+@ExperimentalLatencyDataApi
+@UiThread
+internal class LatencyDataPool(numPreAllocatedInstances: Int = 100) {
+    private val pool = ArrayDeque<LatencyData>(numPreAllocatedInstances)
+
+    init {
+        repeat(numPreAllocatedInstances) { recycle(LatencyData()) }
+    }
+
+    /**
+     * Gets a [LatencyData] from the pool, or a newly allocated one if the pool is empty.
+     *
+     * Allocations in response to `obtain()` are undesirable since they may trigger garbage
+     * collection, causing latency or jank.
+     */
+    fun obtain(): LatencyData {
+        return pool.removeFirstOrNull()
+            ?: LatencyData().also {
+                Log.w(
+                    this::class.simpleName,
+                    "Pool is empty; allocating a LatencyData. You should have preallocated more instances.",
+                )
+            }
+    }
+
+    /** Puts a [LatencyData] into the pool for later reuse. */
+    fun recycle(latencyData: LatencyData) {
+        latencyData.reset()
+        pool.addLast(latencyData)
+    }
+
+    /**
+     * [obtain]s a [LatencyData] and sets its [LatencyData.EventAction] and
+     * [LatencyData.osDetectsEvent] fields from the given event. Ignores "historical" events that
+     * might be batched with the primary (most recent) event.
+     */
+    fun obtainLatencyDataForSingleEvent(
+        event: MotionEvent?,
+        inProgressStrokeAction: LatencyData.StrokeAction,
+        inProgressStrokeId: InProgressStrokeId?,
+        strokesViewGetsActionTimeNanos: Long,
+        predicted: Boolean = false,
+    ): LatencyData {
+        return obtain().apply {
+            if (inProgressStrokeId != null) strokeId = inProgressStrokeId
+            strokeAction = inProgressStrokeAction
+            if (event != null) {
+                eventAction = LatencyData.EventAction.fromMotionEvent(event, predicted)
+                batchSize = event.historySize + 1
+                batchIndex = batchSize - 1
+                osDetectsEvent = event.getPrimaryEventTimeNanos()
+            }
+            strokesViewGetsAction = strokesViewGetsActionTimeNanos
+        }
+    }
+
+    /**
+     * [obtain]s a [LatencyData] for the primary (most recent) event in a [MotionEvent] and also for
+     * each historical event that was batched in with it. (See
+     * [MotionEvent.getHistoricalEventTimeNanos].) Also initializes the [LatencyData.EventAction]
+     * and [LatencyData.osDetectsEvent] fields in each such [LatencyData].
+     *
+     * @param event the [MotionEvent] from which to pull primary and historical event times.
+     * @param predicted whether this event came from a prediction engine.
+     * @param datas output argument: a queue to be populated with the [LatencyData]s produced.
+     */
+    fun obtainLatencyDataForPrimaryAndHistoricalEvents(
+        event: MotionEvent,
+        inProgressStrokeAction: LatencyData.StrokeAction,
+        inProgressStrokeId: InProgressStrokeId,
+        strokesViewGetsActionTimeNanos: Long,
+        predicted: Boolean,
+        datas: MutableCollection<LatencyData>,
+    ) {
+        datas.clear()
+        val action = LatencyData.EventAction.fromMotionEvent(event, predicted)
+        val historySize = event.historySize
+        // Note that this loop is inclusive of historySize, which signals the primary event.
+        for (historyIndex in 0..historySize) {
+            datas.add(
+                obtain().apply {
+                    strokeId = inProgressStrokeId
+                    strokeAction = inProgressStrokeAction
+                    eventAction = action
+                    batchSize = historySize + 1
+                    batchIndex = historyIndex
+                    osDetectsEvent = event.getMaybeHistoricalEventTimeNanos(historyIndex)
+                    strokesViewGetsAction = strokesViewGetsActionTimeNanos
+                }
+            )
+        }
+    }
+
+    /**
+     * Gets the time in nanoseconds of an event batched into this [MotionEvent]. If [historyIndex]
+     * is less than [MotionEvent.getHistorySize], it refers to a "historical" point that was
+     * previously unreported but was batched into this event. If it is equal, then it refers to the
+     * primary (most recent) event.
+     *
+     * See [MotionEvent.getHistoricalEventTimeNanos].
+     */
+    private fun MotionEvent.getMaybeHistoricalEventTimeNanos(historyIndex: Int): Long {
+        return if (historyIndex == historySize) {
+            getPrimaryEventTimeNanos()
+        } else {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                getHistoricalEventTimeNanos(historyIndex)
+            } else {
+                getHistoricalEventTime(historyIndex) * 1_000_000L
+            }
+        }
+    }
+
+    private fun MotionEvent.getPrimaryEventTimeNanos(): Long {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            eventTimeNanos
+        } else {
+            eventTime * 1_000_000L
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/InputStreamBuilder.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/InputStreamBuilder.kt
new file mode 100644
index 0000000..96cb203
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/InputStreamBuilder.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.testing
+
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.MotionEvent
+import android.view.MotionEvent.PointerCoords
+import android.view.MotionEvent.PointerProperties
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Helper to build MotionEvents on demand to simulate a stream of input traveling over time from
+ * ACTION_DOWN, through ACTION_MOVE, and ending in ACTION_UP.
+ *
+ * MotionEvents will be generated with a frequency of timeIncrement, but will embed historical
+ * motion at 2x timeIncrement.
+ *
+ * Note that the timestamps of the generated events start with SystemClock.uptimeMillis and thus
+ * aren't suitable for deterministic line rendering.
+ *
+ * MotionEvents that don't use SystemClock.uptimeMillis as their base won't be received if
+ * dispatched on an Android View.
+ *
+ * If deterministic line rendering is needed for a test, best is to dispatch input with fixed
+ * timestamps directly to the LegacyStrokeBuilder using its own input format.
+ *
+ * Consider if you can use [MultiTouchInputBuilder] instead with pointerCount=1, since as we
+ * continue to generalize that utility there may not be much need to maintain this separately.
+ */
+@VisibleForTesting
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+public class InputStreamBuilder(
+    private val streamToolType: Int = MotionEvent.TOOL_TYPE_STYLUS,
+    private val buttons: Int = 0,
+    private val pointerId: Int = 0,
+    private val startX: Float = 0F,
+    private val startY: Float = 0F,
+    private val timeIncrement: Long = 10,
+    private val xIncrement: Float = 50F,
+    private val yIncrement: Float = 100F,
+    private val cancel: Boolean = false,
+) {
+    private val streamDownTime: Long = SystemClock.uptimeMillis()
+    private var moveCount = 0
+
+    /**
+     * Runs a [block] with the down event for this input stream and manages the MotionEvent clean
+     * up.
+     */
+    public fun runWithDownEvent(block: (MotionEvent) -> Unit) {
+        getDownEvent().use(block)
+    }
+
+    /**
+     * Runs a [block] with the next move event for this input stream and manages the MotionEvent
+     * clean up.
+     */
+    public fun runWithMoveEvent(block: (MotionEvent) -> Unit) {
+        getNextMoveEvent().use(block)
+    }
+
+    /**
+     * Runs a [block] with the up event for this input stream and manages the MotionEvent clean up.
+     */
+    public fun runWithUpEvent(block: (MotionEvent) -> Unit) {
+        getUpEvent().use(block)
+    }
+
+    /**
+     * Runs a [block] with the down, move, and up event for this input stream and manages the
+     * MotionEvent clean up.
+     */
+    public fun runInputStreamWith(block: (MotionEvent) -> Unit) {
+        runWithDownEvent(block)
+        runWithMoveEvent(block)
+        runWithUpEvent(block)
+    }
+
+    /**
+     * The initial MotionEvent with ACTION_DOWN for a stream of input. Caller should call recycle()
+     * to clean up resources of MotionEvent after use. Consider using method [runWithMoveEvent] to
+     * avoid managing the clean up call to MotionEvent.recycle() explicitly.
+     */
+    public fun getDownEvent(): MotionEvent {
+        return obtainWithDefaults(
+            eventTime = streamDownTime,
+            action = MotionEvent.ACTION_DOWN,
+            pointerProperties =
+                arrayOf(
+                    PointerProperties().apply {
+                        id = pointerId
+                        toolType = streamToolType
+                    }
+                ),
+            pointerCoords =
+                arrayOf(
+                    PointerCoords().apply {
+                        x = startX
+                        y = startY
+                    }
+                ),
+            buttonState = buttons,
+        )
+    }
+
+    /**
+     * The next MotionEvent with ACTION_MOVE for a stream of input where the eventTime, pointer x
+     * position, and pointer y position are all incremented from the previous MotionEvent. Caller
+     * should call recycle() to clean up resources of MotionEvent after use. Consider using method
+     * [runWithMoveEvent] to avoid managing the clean up call to MotionEvent.recycle() explicitly.
+     */
+    public fun getNextMoveEvent(): MotionEvent {
+        moveCount++
+        return obtainWithDefaults(
+                eventTime = (streamDownTime + (moveCount - 0.5) * timeIncrement).toLong(),
+                action = MotionEvent.ACTION_MOVE,
+                pointerProperties =
+                    arrayOf(
+                        PointerProperties().apply {
+                            id = pointerId
+                            toolType = streamToolType
+                        }
+                    ),
+                pointerCoords =
+                    arrayOf(
+                        PointerCoords().apply {
+                            x = startX + xIncrement * (moveCount - 0.5f)
+                            y = startY + yIncrement * (moveCount - 0.5f)
+                        }
+                    ),
+                buttonState = buttons,
+            )
+            .apply {
+                addBatch(
+                    (streamDownTime + moveCount * timeIncrement).toLong(),
+                    arrayOf(
+                        PointerCoords().apply {
+                            x = startX + xIncrement * moveCount
+                            y = startY + yIncrement * moveCount
+                        }
+                    ),
+                    0,
+                )
+            }
+    }
+
+    /**
+     * The final MotionEvent with ACTION_UP for a stream of input where the eventTime, pointer x
+     * position, and pointer y position are all incremented from the previous MotionEvent. Caller
+     * should call recycle() to clean up resources of MotionEvent after use. Consider using method
+     * [runWithUpEvent] to avoid managing the clean up call to MotionEvent.recycle() explicitly.
+     */
+    public fun getUpEvent(): MotionEvent {
+        val action = if (cancel) MotionEvent.ACTION_CANCEL else MotionEvent.ACTION_UP
+        moveCount++
+        return obtainWithDefaults(
+            eventTime = streamDownTime + moveCount * timeIncrement,
+            action = action,
+            pointerProperties =
+                arrayOf(
+                    PointerProperties().apply {
+                        id = pointerId
+                        toolType = streamToolType
+                    }
+                ),
+            pointerCoords =
+                arrayOf(
+                    PointerCoords().apply {
+                        x = startX + xIncrement * moveCount
+                        y = startY + yIncrement * moveCount
+                    }
+                ),
+            buttonState = 0,
+        )
+    }
+
+    public companion object {
+
+        /**
+         * Creates a stylus line of 3 motion events, designed to be called with [runInputStreamWith]
+         * or the sequence [runWithDownEvent], [runWithMoveEvent], [runWithUpEvent].
+         */
+        public fun stylusLine(
+            startX: Float,
+            startY: Float,
+            endX: Float,
+            endY: Float,
+            endWithCancel: Boolean = false,
+        ): InputStreamBuilder =
+            InputStreamBuilder(
+                streamToolType = MotionEvent.TOOL_TYPE_STYLUS,
+                pointerId = 1,
+                startX = startX,
+                startY = startY,
+                xIncrement = (endX - startX) / 2,
+                yIncrement = (endY - startY) / 2,
+                cancel = endWithCancel,
+            )
+
+        /**
+         * Creates a finger-drawn line of 3 motion events, designed to be called with
+         * [runInputStreamWith] or the sequence [runWithDownEvent], [runWithMoveEvent], and
+         * [runWithUpEvent].
+         */
+        public fun fingerLine(
+            startX: Float,
+            startY: Float,
+            endX: Float,
+            endY: Float,
+            endWithCancel: Boolean = false,
+        ): InputStreamBuilder =
+            InputStreamBuilder(
+                streamToolType = MotionEvent.TOOL_TYPE_FINGER,
+                pointerId = 1,
+                startX = startX,
+                startY = startY,
+                xIncrement = (endX - startX) / 2,
+                yIncrement = (endY - startY) / 2,
+                cancel = endWithCancel,
+            )
+
+        /**
+         * Creates a mouse-drawn line of 3 motion events, designed to be called with
+         * [runInputStreamWith] or the sequence [runWithDownEvent], [runWithMoveEvent], and
+         * [runWithUpEvent].
+         */
+        public fun mouseLine(
+            buttons: Int,
+            startX: Float,
+            startY: Float,
+            endX: Float,
+            endY: Float,
+            endWithCancel: Boolean = false,
+        ): InputStreamBuilder =
+            InputStreamBuilder(
+                streamToolType = MotionEvent.TOOL_TYPE_MOUSE,
+                buttons = buttons,
+                pointerId = 1,
+                startX = startX,
+                startY = startY,
+                xIncrement = (endX - startX) / 2,
+                yIncrement = (endY - startY) / 2,
+                cancel = endWithCancel,
+            )
+
+        public fun scrollWheel(scrollHorz: Float, scrollVert: Float, block: (MotionEvent) -> Unit) {
+            val event: MotionEvent =
+                obtainWithDefaults(
+                    eventTime = SystemClock.uptimeMillis(),
+                    action = MotionEvent.ACTION_SCROLL,
+                    pointerProperties =
+                        arrayOf(
+                            PointerProperties().apply {
+                                id = 0
+                                toolType = MotionEvent.TOOL_TYPE_MOUSE
+                            }
+                        ),
+                    pointerCoords =
+                        arrayOf(
+                            PointerCoords().apply {
+                                setAxisValue(MotionEvent.AXIS_HSCROLL, scrollHorz)
+                                setAxisValue(MotionEvent.AXIS_VSCROLL, scrollVert)
+                            }
+                        ),
+                    source = InputDevice.SOURCE_MOUSE,
+                )
+            event.use(block)
+        }
+
+        /**
+         * Helper function wrapping a MotionEvent.obtain() function such that defaults are set and
+         * parameter names can be listed at the time of call to this function, improving readability
+         * of the long list of often unimportant parameters.
+         */
+        private fun obtainWithDefaults(
+            downTime: Long = 1000L,
+            eventTime: Long,
+            action: Int,
+            pointerCount: Int = 1,
+            pointerProperties: Array<MotionEvent.PointerProperties>,
+            pointerCoords: Array<MotionEvent.PointerCoords>,
+            metaState: Int = 0,
+            buttonState: Int = 0,
+            xPrecision: Float = 0.001F,
+            yPrecision: Float = 0.001F,
+            deviceId: Int = 0,
+            edgeFlags: Int = 0,
+            source: Int = 0,
+            flags: Int = 0,
+        ) =
+            MotionEvent.obtain(
+                downTime,
+                eventTime,
+                action,
+                pointerCount,
+                pointerProperties,
+                pointerCoords,
+                metaState,
+                buttonState,
+                xPrecision,
+                yPrecision,
+                deviceId,
+                edgeFlags,
+                source,
+                flags,
+            )
+
+        private fun MotionEvent.use(block: (MotionEvent) -> Unit) {
+            try {
+                block(this)
+            } finally {
+                recycle()
+            }
+        }
+    }
+}
diff --git a/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilder.kt b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilder.kt
new file mode 100644
index 0000000..efbd75b
--- /dev/null
+++ b/ink/ink-authoring/src/androidMain/kotlin/androidx/ink/authoring/testing/MultiTouchInputBuilder.kt
@@ -0,0 +1,400 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ink.authoring.testing
+
+import android.view.MotionEvent
+import android.view.MotionEvent.AXIS_TILT
+import android.view.MotionEvent.PointerCoords
+import android.view.MotionEvent.PointerProperties
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Builds MotionEvents on demand to simulate 2 to 5 streams of input traveling over time from
+ * ACTION_DOWN, through ACTION_MOVE, and ending in ACTION_UP, where pointers 2 and higher will be
+ * added incrementally in ascending order with ACTION_POINTER_DOWN, all pointers will have
+ * ACTION_MOVE run 4x times, and then will be removed incrementally in descending order with
+ * ACTION_POINTER_UP. [pointerId], [startX], [startY], [xIncrement], and [yIncrement] are arrays of
+ * values for each pointer in the gesture, such that startX = [10, 50, 75] would mean that the first
+ * pointer starts at x=10, the second pointer starts at x=50, and the third pointer starts at x=75.
+ * The default values are the appropriate size for the [pointerCount], but if you provide your own
+ * be sure that their sizes are all equal to the [pointerCount].
+ *
+ * Note that the timestamps of the generated events start with downtime = 1000L and not
+ * SystemClock.uptimeMillis.
+ *
+ * MotionEvents that aren't close enough to system time aren't guaranteed to be received if
+ * dispatched on an Android View. For this use case, make sure to set [downtime] to
+ * [android.os.SystemClock.uptimeMillis].
+ *
+ * Change [historyIncrements] to have more than one input point per [MotionEvent]. It represents the
+ * number of steps from the previous event to the next one, so there will be `historyIncrements - 1`
+ * historical events preceding the primary [MotionEvent] data.
+ */
+@VisibleForTesting
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+public class MultiTouchInputBuilder(
+    private val pointerCount: Int = 2,
+    private val pointerId: IntArray = IntArray(pointerCount) { 9000 + it },
+    private val toolTypes: IntArray = IntArray(pointerCount) { MotionEvent.TOOL_TYPE_FINGER },
+    private val startX: FloatArray = FloatArray(pointerCount) { 100F * it },
+    private val startY: FloatArray = FloatArray(pointerCount),
+    /** Set an entry to null if that pointer should not have pressure. */
+    private val startPressure: Array<Float?> =
+        Array(pointerCount) {
+            if (toolTypes[it] == MotionEvent.TOOL_TYPE_STYLUS) 0.05F * (it + 1) else null
+        },
+    /** Set an entry to null if that pointer should not have orientation. */
+    private val startOrientation: Array<Float?> =
+        Array(pointerCount) {
+            if (toolTypes[it] == MotionEvent.TOOL_TYPE_STYLUS) 0.01F * (it + 1) else null
+        },
+    /**
+     * Set an entry to null if that pointer should not have tilt.
+     *
+     * Note: Tilt does not seem to be supported currently in Robolectric, but it can be used for
+     * emulator/device tests.
+     */
+    private val startTilt: Array<Float?> =
+        Array(pointerCount) {
+            if (toolTypes[it] == MotionEvent.TOOL_TYPE_STYLUS) 0.07F * (it + 1) else null
+        },
+    private val historyIncrements: Int = 1,
+    private val timeIncrementMillis: Long = 10L * historyIncrements,
+    private val xIncrement: FloatArray = FloatArray(pointerCount) { 100F },
+    private val yIncrement: FloatArray = FloatArray(pointerCount) { 100F },
+    private val pressureIncrement: FloatArray = FloatArray(pointerCount) { 0.1F },
+    private val orientationIncrement: FloatArray = FloatArray(pointerCount) { 0.2F },
+    private val tiltIncrement: FloatArray = FloatArray(pointerCount) { 0.1F },
+    private val downFlags: IntArray = IntArray(pointerCount),
+    private val upFlags: IntArray = IntArray(pointerCount),
+    private val downtime: Long = 1000L,
+) {
+    private var moveCount: Int = 0
+
+    init {
+        require(timeIncrementMillis % historyIncrements == 0L) {
+            "Time increment ($timeIncrementMillis) must be a multiple of the history increments ($historyIncrements)."
+        }
+        require(
+            pointerId.size == pointerCount &&
+                toolTypes.size == pointerCount &&
+                startX.size == pointerCount &&
+                startY.size == pointerCount &&
+                startPressure.size == pointerCount &&
+                startOrientation.size == pointerCount &&
+                startTilt.size == pointerCount &&
+                xIncrement.size == pointerCount &&
+                yIncrement.size == pointerCount &&
+                pressureIncrement.size == pointerCount &&
+                orientationIncrement.size == pointerCount &&
+                tiltIncrement.size == pointerCount &&
+                downFlags.size == pointerCount &&
+                upFlags.size == pointerCount
+        ) {
+            "Input arrays must be the same size as the pointerCount."
+        }
+    }
+
+    /**
+     * Perform [block] with a stream of [MotionEvent] such that the stream begins with one pointer
+     * and ACTION_DOWN, where pointers 2 and higher will be added incrementally in ascending order
+     * with ACTION_POINTER_DOWN, all pointers will have ACTION_MOVE run 4x times, and then will be
+     * removed incrementally in descending order with ACTION_POINTER_UP.
+     */
+    public fun runGestureWith(block: (MotionEvent) -> Unit) {
+        val arrayOfPointerProperties = ArrayList<PointerProperties>()
+        val arrayOfPointerCoords = ArrayList<PointerCoords>()
+
+        // Add each pointer incrementally.
+        for (p in 0 until pointerCount) {
+            arrayOfPointerProperties.add(
+                PointerProperties().apply {
+                    id = pointerId[p]
+                    toolType = toolTypes[p]
+                }
+            )
+            arrayOfPointerCoords.add(
+                PointerCoords().apply {
+                    x = startX[p]
+                    y = startY[p]
+                    startPressure[p]?.let { pressure = it }
+                    startOrientation[p]?.let { orientation = it }
+                    startTilt[p]?.let { setAxisValue(AXIS_TILT, it) }
+                }
+            )
+            val ev =
+                obtainWithDefaults(
+                    downTime = downtime,
+                    eventTime = downtime,
+                    action =
+                        if (p == 0) MotionEvent.ACTION_DOWN
+                        else
+                            (MotionEvent.ACTION_POINTER_DOWN or
+                                (p shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)),
+                    pointerCount = p + 1,
+                    arrayOfPointerProperties.toTypedArray(),
+                    arrayOfPointerCoords.toTypedArray(),
+                    metaState = 0,
+                    buttonState = 0,
+                    xPrecision = 0.001F,
+                    yPrecision = 0.001F,
+                    deviceId = 0,
+                    edgeFlags = 0,
+                    source = 0,
+                    flags = downFlags[p],
+                )
+            ev.use(block)
+        }
+        moveCount++
+
+        // Perform 4x move actions with all pointers touching.
+        repeat(4) {
+            // Treat the original event obtain as the first history increment.
+            for (p in 0 until pointerCount) {
+                val previousPointerCoords = arrayOfPointerCoords[p]
+                arrayOfPointerCoords[p] =
+                    PointerCoords().apply {
+                        x = previousPointerCoords.x + xIncrement[p] / historyIncrements
+                        y = previousPointerCoords.y + yIncrement[p] / historyIncrements
+                        if (startPressure[p] != null) {
+                            pressure =
+                                previousPointerCoords.pressure +
+                                    pressureIncrement[p] / historyIncrements
+                        }
+                        if (startOrientation[p] != null) {
+                            orientation =
+                                previousPointerCoords.orientation +
+                                    orientationIncrement[p] / historyIncrements
+                        }
+                        if (startTilt[p] != null) {
+                            setAxisValue(
+                                AXIS_TILT,
+                                previousPointerCoords.getAxisValue(AXIS_TILT) +
+                                    tiltIncrement[p] / historyIncrements,
+                            )
+                        }
+                    }
+            }
+            val ev =
+                obtainWithDefaults(
+                    downTime = downtime,
+                    eventTime =
+                        downtime +
+                            (moveCount - 1) * timeIncrementMillis +
+                            timeIncrementMillis / historyIncrements,
+                    action = MotionEvent.ACTION_MOVE,
+                    pointerCount,
+                    arrayOfPointerProperties.toTypedArray(),
+                    arrayOfPointerCoords.toTypedArray(),
+                    metaState = 0,
+                    buttonState = 0,
+                    xPrecision = 0.001F,
+                    yPrecision = 0.001F,
+                    deviceId = 0,
+                    edgeFlags = 0,
+                    source = 0,
+                    flags = 0,
+                )
+            // Start with the second history increment and go all the way through the primary event
+            // value.
+            for (h in 2..historyIncrements) {
+                for (p in 0 until pointerCount) {
+                    val previousPointerCoords = arrayOfPointerCoords[p]
+                    arrayOfPointerCoords[p] =
+                        PointerCoords().apply {
+                            x = previousPointerCoords.x + xIncrement[p] / historyIncrements
+                            y = previousPointerCoords.y + yIncrement[p] / historyIncrements
+                            if (startPressure[p] != null) {
+                                pressure =
+                                    previousPointerCoords.pressure +
+                                        pressureIncrement[p] / historyIncrements
+                            }
+                            if (startOrientation[p] != null) {
+                                orientation =
+                                    previousPointerCoords.orientation +
+                                        orientationIncrement[p] / historyIncrements
+                            }
+                            if (startTilt[p] != null) {
+                                setAxisValue(
+                                    AXIS_TILT,
+                                    previousPointerCoords.getAxisValue(AXIS_TILT) +
+                                        tiltIncrement[p] / historyIncrements,
+                                )
+                            }
+                        }
+                }
+                ev.addBatch(
+                    downtime +
+                        (moveCount - 1) * timeIncrementMillis +
+                        (timeIncrementMillis / historyIncrements) * h,
+                    arrayOfPointerCoords.toTypedArray(),
+                    /* metaState = */ 0,
+                )
+            }
+            ev.use(block)
+            moveCount++
+        }
+
+        // Remove each pointer incrementally. Up events do not contain history, so ignore
+        // `historyIncrements`.
+        for (p in (pointerCount - 1) downTo 0) {
+            val ev =
+                obtainWithDefaults(
+                    downTime = downtime,
+                    eventTime = downtime + moveCount * timeIncrementMillis,
+                    action =
+                        if (p == 0) MotionEvent.ACTION_UP
+                        else
+                            (MotionEvent.ACTION_POINTER_UP or
+                                (p shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)),
+                    pointerCount = p + 1,
+                    arrayOfPointerProperties.toTypedArray(),
+                    arrayOfPointerCoords.toTypedArray(),
+                    metaState = 0,
+                    buttonState = 0,
+                    xPrecision = 0.001F,
+                    yPrecision = 0.001F,
+                    deviceId = 0,
+                    edgeFlags = 0,
+                    source = 0,
+                    flags = upFlags[p],
+                )
+            ev.use(block)
+            arrayOfPointerProperties.removeAt(p)
+            arrayOfPointerCoords.removeAt(p)
+        }
+        moveCount++
+    }
+
+    private fun MotionEvent.use(block: (MotionEvent) -> Unit) {
+        try {
+            block(this)
+        } finally {
+            recycle()
+        }
+    }
+
+    public companion object {
+        /**
+         * Creates a stream of MotionEvents for a two-finger gesture such that the canvas shows
+         * strokes at [zoomFactor] times their original size centered about ([centerX], [centerY]).
+         *
+         * Example: zoomFactor = 2 will make all strokes appear twice their original size. Strokes
+         * will be larger on the screen and less of the canvas will be shown.
+         */
+        public fun pinchOutWithFactor(
+            centerX: Float,
+            centerY: Float,
+            zoomFactor: Float = 2F,
+        ): MultiTouchInputBuilder =
+            MultiTouchInputBuilder(
+                pointerCount = 2,
+                startX = floatArrayOf(centerX - 100F, centerX + 100F),
+                startY = floatArrayOf(centerY, centerY),
+                timeIncrementMillis = 10,
+                xIncrement =
+                    floatArrayOf(
+                        if (zoomFactor <= 1f) 0F else -(100 / 4F) * (zoomFactor - 1),
+                        if (zoomFactor <= 1f) 0F else (100 / 4F) * (zoomFactor - 1),
+                    ),
+                yIncrement = floatArrayOf(0F, 0F),
+            )
+
+        /**
+         * Creates a stream of MotionEvents for a two-finger gesture such that the canvas shows
+         * strokes at [zoomFactor] times their original size centered about ([centerX], [centerY]).
+         *
+         * Example: zoomFactor = 0.5 will make all strokes appear half their original size. Strokes
+         * will be smaller on the screen and more of the canvas will be shown.
+         */
+        public fun pinchInWithFactor(
+            centerX: Float,
+            centerY: Float,
+            zoomFactor: Float = 0.5F,
+        ): MultiTouchInputBuilder =
+            MultiTouchInputBuilder(
+                pointerCount = 2,
+                startX = floatArrayOf(centerX - 100F, centerX + 100F),
+                startY = floatArrayOf(centerY, centerY),
+                timeIncrementMillis = 10,
+                xIncrement =
+                    floatArrayOf(
+                        if (zoomFactor <= 0f || zoomFactor >= 1F) 0F
+                        else 100 * (1 - zoomFactor) / 4F,
+                        if (zoomFactor <= 0f || zoomFactor >= 1F) 0F
+                        else -100 * (1 - zoomFactor) / 4F,
+                    ),
+                yIncrement = floatArrayOf(0F, 0F),
+            )
+
+        /**
+         * Creates a stream of MotionEvents for a two-finger gesture such that the canvas rotates 90
+         * degrees clockwise centered about ([centerX], [centerY]).
+         */
+        public fun rotate90DegreesClockwise(
+            centerX: Float,
+            centerY: Float
+        ): MultiTouchInputBuilder =
+            MultiTouchInputBuilder(
+                pointerCount = 2,
+                startX = floatArrayOf(centerX - 50F, centerX + 50),
+                startY = floatArrayOf(centerY - 50F, centerY + 50F),
+                timeIncrementMillis = 10,
+                xIncrement = floatArrayOf(25F, -25F),
+                yIncrement = floatArrayOf(0F, 0F),
+            )
+
+        /**
+         * Helper function wrapping a MotionEvent.obtain() function such that defaults are set and
+         * parameter names can be listed at the time of call to this function, improving readability
+         * of the long list of often unimportant parameters.
+         */
+        private fun obtainWithDefaults(
+            downTime: Long = 1000L,
+            eventTime: Long,
+            action: Int,
+            pointerCount: Int = 1,
+            pointerProperties: Array<PointerProperties>,
+            pointerCoords: Array<PointerCoords>,
+            metaState: Int = 0,
+            buttonState: Int = 0,
+            xPrecision: Float = 0.001F,
+            yPrecision: Float = 0.001F,
+            deviceId: Int = 0,
+            edgeFlags: Int = 0,
+            source: Int = 0,
+            flags: Int = 0,
+        ): MotionEvent =
+            MotionEvent.obtain(
+                downTime,
+                eventTime,
+                action,
+                pointerCount,
+                pointerProperties,
+                pointerCoords,
+                metaState,
+                buttonState,
+                xPrecision,
+                yPrecision,
+                deviceId,
+                edgeFlags,
+                source,
+                flags,
+            )
+    }
+}
diff --git a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
index 9697d8c..10f81dd 100644
--- a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
+++ b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushBehavior.kt
@@ -23,7 +23,6 @@
 import kotlin.jvm.JvmField
 import kotlin.jvm.JvmOverloads
 import kotlin.jvm.JvmStatic
-import kotlin.reflect.KClass
 
 /**
  * A behavior describing how stroke input properties should affect the shape and color of the brush
@@ -61,8 +60,7 @@
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
 @ExperimentalInkCustomBrushApi
 // NotCloseable: Finalize is only used to free the native peer.
-// Deprecation: b/356424519 Migrate to targetNodes
-@Suppress("NotCloseable", "DEPRECATION")
+@Suppress("NotCloseable")
 public class BrushBehavior(
     // The [targetNodes] val below is a defensive copy of this parameter.
     targetNodes: List<TargetNode>
@@ -128,134 +126,6 @@
     )
 
     /**
-     * Returns a node in the behavior of the given [Node] subclass, matching the given predicate (if
-     * any).
-     */
-    // TODO: b/356424519 - Remove this method once the below legacy properties are removed.
-    private fun <T : Node> findNode(
-        nodeClass: KClass<T>,
-        predicate: (T) -> Boolean = { true }
-    ): T? {
-        val stack = ArrayDeque<Node>(targetNodes)
-        while (!stack.isEmpty()) {
-            val node = stack.removeLast()
-            // [KClass.safeCast] is apparently discouraged on Android for performance reasons.
-            if (nodeClass.isInstance(node)) {
-                @Suppress("UNCHECKED_CAST") // cast is protected by enclosing if statement
-                val result = node as T
-                if (predicate(result)) return result
-            }
-            stack.addAll(node.inputs)
-        }
-        return null
-    }
-
-    // The below properties are implemented so as to give the correct answer in cases where the
-    // [BrushBehavior] was created using the legacy convenience constructor, and to give _some_ kind
-    // of plausible answer in the more general case of a [BrushBehavior] created with an arbitrary
-    // node graph. Once existing callers of these properties are migrated to working with [Node]s
-    // instead, we can remove them.
-    //
-    // TODO: b/356424519 - Remove the below getters once we no longer need them.
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val source: Source
-        get() = findNode(SourceNode::class)?.source ?: Source.NORMALIZED_PRESSURE
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val target: Target
-        get() = findNode(TargetNode::class)?.target ?: Target.SIZE_MULTIPLIER
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val sourceValueRangeLowerBound: Float
-        get() = findNode(SourceNode::class)?.sourceValueRangeLowerBound ?: 0.0f
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val sourceValueRangeUpperBound: Float
-        get() = findNode(SourceNode::class)?.sourceValueRangeUpperBound ?: 1.0f
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val targetModifierRangeLowerBound: Float
-        get() = findNode(TargetNode::class)?.targetModifierRangeLowerBound ?: 0.0f
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val targetModifierRangeUpperBound: Float
-        get() = findNode(TargetNode::class)?.targetModifierRangeUpperBound ?: 1.0f
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val sourceOutOfRangeBehavior: OutOfRange
-        get() = findNode(SourceNode::class)?.sourceOutOfRangeBehavior ?: OutOfRange.CLAMP
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val responseCurve: EasingFunction
-        get() = findNode(ResponseNode::class)?.responseCurve ?: EasingFunction.Predefined.LINEAR
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val responseTimeMillis: Long
-        get() =
-            ((findNode(DampingNode::class, { it.dampingSource == DampingSource.TIME_IN_SECONDS })
-                    ?.dampingGap ?: 0.0f) * 1000.0f)
-                .toLong()
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val enabledToolTypes: Set<InputToolType>
-        get() = findNode(ToolTypeFilterNode::class)?.enabledToolTypes ?: ALL_TOOL_TYPES
-
-    @Deprecated("Prefer using targetNodes instead.")
-    public val isFallbackFor: OptionalInputProperty?
-        get() = findNode(FallbackFilterNode::class)?.isFallbackFor
-
-    /**
-     * Creates a copy of `this` and allows named properties to be altered while keeping the rest
-     * unchanged.
-     */
-    @JvmSynthetic
-    public fun copy(
-        source: Source = this.source,
-        target: Target = this.target,
-        sourceOutOfRangeBehavior: OutOfRange = this.sourceOutOfRangeBehavior,
-        sourceValueRangeLowerBound: Float = this.sourceValueRangeLowerBound,
-        sourceValueRangeUpperBound: Float = this.sourceValueRangeUpperBound,
-        targetModifierRangeLowerBound: Float = this.targetModifierRangeLowerBound,
-        targetModifierRangeUpperBound: Float = this.targetModifierRangeUpperBound,
-        responseCurve: EasingFunction = this.responseCurve,
-        responseTimeMillis: Long = this.responseTimeMillis,
-        enabledToolTypes: Set<InputToolType> = this.enabledToolTypes,
-        isFallbackFor: OptionalInputProperty? = this.isFallbackFor,
-    ): BrushBehavior =
-        BrushBehavior(
-            source,
-            target,
-            sourceValueRangeLowerBound,
-            sourceValueRangeUpperBound,
-            targetModifierRangeLowerBound,
-            targetModifierRangeUpperBound,
-            sourceOutOfRangeBehavior,
-            responseCurve,
-            responseTimeMillis,
-            enabledToolTypes,
-            isFallbackFor,
-        )
-
-    /**
-     * Returns a [Builder] with values set equivalent to `this`. Java developers, use the returned
-     * builder to build a copy of a BrushBehavior.
-     */
-    public fun toBuilder(): Builder =
-        Builder()
-            .setSource(source)
-            .setTarget(target)
-            .setSourceOutOfRangeBehavior(sourceOutOfRangeBehavior)
-            .setSourceValueRangeLowerBound(sourceValueRangeLowerBound)
-            .setSourceValueRangeUpperBound(sourceValueRangeUpperBound)
-            .setTargetModifierRangeLowerBound(targetModifierRangeLowerBound)
-            .setTargetModifierRangeUpperBound(targetModifierRangeUpperBound)
-            .setResponseCurve(responseCurve)
-            .setResponseTimeMillis(responseTimeMillis)
-            .setEnabledToolTypes(enabledToolTypes)
-            .setIsFallbackFor(isFallbackFor)
-
-    /**
      * Builder for [BrushBehavior].
      *
      * For Java developers, use BrushBehavior.Builder to construct a [BrushBehavior] with default
@@ -363,7 +233,6 @@
     }
 
     private fun createNativeBrushBehavior(targetNodes: List<TargetNode>): Long {
-        // TODO: b/356424519 - Use dup/swap nodes to avoid repeating common subexpressions.
         val orderedNodes = ArrayDeque<Node>()
         val stack = ArrayDeque<Node>(targetNodes)
         while (!stack.isEmpty()) {
diff --git a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushFamily.kt b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushFamily.kt
index a0ef9e6..66b2da9 100644
--- a/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushFamily.kt
+++ b/ink/ink-brush/src/jvmAndroidMain/kotlin/androidx/ink/brush/BrushFamily.kt
@@ -19,6 +19,7 @@
 import androidx.annotation.RestrictTo
 import androidx.ink.nativeloader.NativeLoader
 import java.util.Collections.unmodifiableList
+import kotlin.jvm.JvmField
 import kotlin.jvm.JvmOverloads
 import kotlin.jvm.JvmStatic
 
@@ -42,6 +43,8 @@
     coats: List<BrushCoat>,
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     public val uri: String? = null,
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+    public val inputModel: InputModel = DEFAULT_INPUT_MODEL,
 ) {
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     public val coats: List<BrushCoat> = unmodifiableList(coats.toList())
@@ -53,11 +56,16 @@
         tip: BrushTip = BrushTip(),
         paint: BrushPaint = BrushPaint(),
         uri: String? = null,
-    ) : this(listOf(BrushCoat(tip, paint)), uri)
+        inputModel: InputModel = DEFAULT_INPUT_MODEL,
+    ) : this(listOf(BrushCoat(tip, paint)), uri, inputModel)
 
     /** A handle to the underlying native [BrushFamily] object. */
     internal val nativePointer: Long =
-        nativeCreateBrushFamily(coats.map { it.nativePointer }.toLongArray(), uri)
+        nativeCreateBrushFamily(
+            coats.map { it.nativePointer }.toLongArray(),
+            uri,
+            inputModel === SpringModel,
+        )
 
     /**
      * Creates a copy of `this` and allows named properties to be altered while keeping the rest
@@ -66,11 +74,15 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     @ExperimentalInkCustomBrushApi
     @JvmSynthetic
-    public fun copy(coats: List<BrushCoat> = this.coats, uri: String? = this.uri): BrushFamily {
-        return if (coats == this.coats && uri == this.uri) {
+    public fun copy(
+        coats: List<BrushCoat> = this.coats,
+        uri: String? = this.uri,
+        inputModel: InputModel = this.inputModel,
+    ): BrushFamily {
+        return if (coats == this.coats && uri == this.uri && inputModel == this.inputModel) {
             this
         } else {
-            BrushFamily(coats, uri)
+            BrushFamily(coats, uri, inputModel)
         }
     }
 
@@ -81,8 +93,12 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     @ExperimentalInkCustomBrushApi
     @JvmSynthetic
-    public fun copy(coat: BrushCoat, uri: String? = this.uri): BrushFamily {
-        return copy(coats = listOf(coat), uri = uri)
+    public fun copy(
+        coat: BrushCoat,
+        uri: String? = this.uri,
+        inputModel: InputModel = this.inputModel,
+    ): BrushFamily {
+        return copy(coats = listOf(coat), uri = uri, inputModel = inputModel)
     }
 
     /**
@@ -92,8 +108,13 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     @ExperimentalInkCustomBrushApi
     @JvmSynthetic
-    public fun copy(tip: BrushTip, paint: BrushPaint, uri: String? = this.uri): BrushFamily {
-        return copy(coat = BrushCoat(tip, paint), uri = uri)
+    public fun copy(
+        tip: BrushTip,
+        paint: BrushPaint,
+        uri: String? = this.uri,
+        inputModel: InputModel = this.inputModel,
+    ): BrushFamily {
+        return copy(coat = BrushCoat(tip, paint), uri = uri, inputModel = inputModel)
     }
 
     /**
@@ -102,7 +123,8 @@
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
     @ExperimentalInkCustomBrushApi
-    public fun toBuilder(): Builder = Builder().setCoats(coats).setUri(uri)
+    public fun toBuilder(): Builder =
+        Builder().setCoats(coats).setUri(uri).setInputModel(inputModel)
 
     /**
      * Builder for [BrushFamily].
@@ -116,6 +138,7 @@
     public class Builder {
         private var coats: List<BrushCoat> = listOf(BrushCoat(BrushTip(), BrushPaint()))
         private var uri: String? = null
+        private var inputModel: InputModel = DEFAULT_INPUT_MODEL
 
         public fun setCoat(tip: BrushTip, paint: BrushPaint): Builder =
             setCoat(BrushCoat(tip, paint))
@@ -132,21 +155,29 @@
             return this
         }
 
-        public fun build(): BrushFamily = BrushFamily(coats, uri)
+        public fun setInputModel(inputModel: InputModel): Builder {
+            this.inputModel = inputModel
+            return this
+        }
+
+        public fun build(): BrushFamily = BrushFamily(coats, uri, inputModel)
     }
 
     override fun equals(other: Any?): Boolean {
         if (other == null || other !is BrushFamily) return false
-        return coats == other.coats && uri == other.uri
+        // NOMUTANTS -- Check the instance first to short circuit faster.
+        if (other === this) return true
+        return coats == other.coats && uri == other.uri && inputModel == other.inputModel
     }
 
     override fun hashCode(): Int {
         var result = coats.hashCode()
         result = 31 * result + uri.hashCode()
+        result = 31 * result + inputModel.hashCode()
         return result
     }
 
-    override fun toString(): String = "BrushFamily(coats=$coats, uri=$uri)"
+    override fun toString(): String = "BrushFamily(coats=$coats, uri=$uri, inputModel=$inputModel)"
 
     /** Deletes native BrushFamily memory. */
     protected fun finalize() {
@@ -156,7 +187,11 @@
 
     /** Create underlying native object and return reference for all subsequent native calls. */
     // TODO: b/355248266 - @Keep must go in Proguard config file instead.
-    private external fun nativeCreateBrushFamily(coatNativePointers: LongArray, uri: String?): Long
+    private external fun nativeCreateBrushFamily(
+        coatNativePointers: LongArray,
+        uri: String?,
+        useSpringModelV2: Boolean,
+    ): Long
 
     /** Release the underlying memory allocated in [nativeCreateBrushFamily]. */
     private external fun nativeFreeBrushFamily(
@@ -174,5 +209,44 @@
         @ExperimentalInkCustomBrushApi
         @JvmStatic
         public fun builder(): Builder = Builder()
+
+        /** The recommended spring-based input modeler. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+        @ExperimentalInkCustomBrushApi
+        @JvmField
+        public val SPRING_MODEL: InputModel = SpringModel
+
+        /**
+         * The legacy spring-based input modeler, provided for backwards compatibility with existing
+         * Ink clients.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+        @ExperimentalInkCustomBrushApi
+        @JvmField
+        public val LEGACY_SPRING_MODEL: InputModel = LegacySpringModel
+
+        /** The default [InputModel] that will be used by a [BrushFamily] when none is specified. */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+        @ExperimentalInkCustomBrushApi
+        @JvmField
+        public val DEFAULT_INPUT_MODEL: InputModel = SPRING_MODEL
+    }
+
+    /**
+     * Specifies a model for turning a sequence of raw hardware inputs (e.g. from a stylus,
+     * touchscreen, or mouse) into a sequence of smoothed, modeled inputs. Raw hardware inputs tend
+     * to be noisy, and must be smoothed before being passed into a brush's behaviors and extruded
+     * into a mesh in order to get a good-looking stroke.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // PublicApiNotReadyForJetpackReview
+    @ExperimentalInkCustomBrushApi
+    public abstract class InputModel internal constructor() {}
+
+    internal object LegacySpringModel : InputModel() {
+        override fun toString(): String = "LegacySpringModel"
+    }
+
+    internal object SpringModel : InputModel() {
+        override fun toString(): String = "SpringModel"
     }
 }
diff --git a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
index 2b071ca..a7d83ad 100644
--- a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
+++ b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushBehaviorTest.kt
@@ -1252,61 +1252,6 @@
     }
 
     @Test
-    fun brushBehaviorCopy_withArguments_createsCopyWithChanges() {
-        val behavior1 =
-            BrushBehavior(
-                source = BrushBehavior.Source.NORMALIZED_PRESSURE,
-                target = BrushBehavior.Target.WIDTH_MULTIPLIER,
-                sourceValueRangeLowerBound = 0.2f,
-                sourceValueRangeUpperBound = .8f,
-                targetModifierRangeLowerBound = 1.1f,
-                targetModifierRangeUpperBound = 1.7f,
-                sourceOutOfRangeBehavior = BrushBehavior.OutOfRange.CLAMP,
-                responseCurve = EasingFunction.Predefined.EASE_IN_OUT,
-                responseTimeMillis = 1L,
-                enabledToolTypes = setOf(InputToolType.STYLUS),
-                isFallbackFor = BrushBehavior.OptionalInputProperty.TILT_X_AND_Y,
-            )
-        assertThat(behavior1.copy(responseTimeMillis = 3L))
-            .isEqualTo(
-                BrushBehavior(
-                    source = BrushBehavior.Source.NORMALIZED_PRESSURE,
-                    target = BrushBehavior.Target.WIDTH_MULTIPLIER,
-                    sourceValueRangeLowerBound = 0.2f,
-                    sourceValueRangeUpperBound = .8f,
-                    targetModifierRangeLowerBound = 1.1f,
-                    targetModifierRangeUpperBound = 1.7f,
-                    sourceOutOfRangeBehavior = BrushBehavior.OutOfRange.CLAMP,
-                    responseCurve = EasingFunction.Predefined.EASE_IN_OUT,
-                    responseTimeMillis = 3L,
-                    enabledToolTypes = setOf(InputToolType.STYLUS),
-                    isFallbackFor = BrushBehavior.OptionalInputProperty.TILT_X_AND_Y,
-                )
-            )
-    }
-
-    @Test
-    fun brushBehaviorCopy_createsCopy() {
-        val behavior1 =
-            BrushBehavior(
-                source = BrushBehavior.Source.NORMALIZED_PRESSURE,
-                target = BrushBehavior.Target.WIDTH_MULTIPLIER,
-                sourceValueRangeLowerBound = 0.2f,
-                sourceValueRangeUpperBound = .8f,
-                targetModifierRangeLowerBound = 1.1f,
-                targetModifierRangeUpperBound = 1.7f,
-                sourceOutOfRangeBehavior = BrushBehavior.OutOfRange.CLAMP,
-                responseCurve = EasingFunction.Predefined.EASE_IN_OUT,
-                responseTimeMillis = 1L,
-                enabledToolTypes = setOf(InputToolType.STYLUS),
-                isFallbackFor = BrushBehavior.OptionalInputProperty.TILT_X_AND_Y,
-            )
-        val behavior2 = behavior1.copy()
-        assertThat(behavior2).isEqualTo(behavior1)
-        assertThat(behavior2).isNotSameInstanceAs(behavior1)
-    }
-
-    @Test
     fun brushBehaviorToString_returnsReasonableString() {
         assertThat(
                 BrushBehavior(
diff --git a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushFamilyTest.kt b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushFamilyTest.kt
index a9197d5..0d368e1 100644
--- a/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushFamilyTest.kt
+++ b/ink/ink-brush/src/jvmAndroidTest/kotlin/androidx/ink/brush/BrushFamilyTest.kt
@@ -47,33 +47,66 @@
     }
 
     @Test
+    fun inputModelHashCode_isSameForIdenticalModels() {
+        assertThat(BrushFamily.LEGACY_SPRING_MODEL.hashCode())
+            .isEqualTo(BrushFamily.LEGACY_SPRING_MODEL.hashCode())
+        assertThat(BrushFamily.SPRING_MODEL.hashCode())
+            .isEqualTo(BrushFamily.SPRING_MODEL.hashCode())
+
+        assertThat(BrushFamily.LEGACY_SPRING_MODEL.hashCode())
+            .isNotEqualTo(BrushFamily.SPRING_MODEL.hashCode())
+    }
+
+    @Test
     fun equals_comparesValues() {
-        val brushFamily = BrushFamily(customTip, customPaint, customUri)
+        val brushFamily =
+            BrushFamily(customTip, customPaint, customUri, BrushFamily.LEGACY_SPRING_MODEL)
         val differentCoat = BrushCoat(BrushTip(), BrushPaint())
         val differentUri = null
 
         // same values are equal.
-        assertThat(brushFamily).isEqualTo(BrushFamily(customTip, customPaint, customUri))
+        assertThat(brushFamily)
+            .isEqualTo(
+                BrushFamily(customTip, customPaint, customUri, BrushFamily.LEGACY_SPRING_MODEL)
+            )
 
         // different values are not equal.
         assertThat(brushFamily).isNotEqualTo(null)
         assertThat(brushFamily).isNotEqualTo(Any())
         assertThat(brushFamily).isNotEqualTo(brushFamily.copy(coat = differentCoat))
         assertThat(brushFamily).isNotEqualTo(brushFamily.copy(uri = differentUri))
+        assertThat(brushFamily)
+            .isNotEqualTo(brushFamily.copy(inputModel = BrushFamily.SPRING_MODEL))
+    }
+
+    @Test
+    fun inputModelEquals_comparesModels() {
+        assertThat(BrushFamily.LEGACY_SPRING_MODEL).isEqualTo(BrushFamily.LEGACY_SPRING_MODEL)
+        assertThat(BrushFamily.SPRING_MODEL).isEqualTo(BrushFamily.SPRING_MODEL)
+
+        assertThat(BrushFamily.LEGACY_SPRING_MODEL).isNotEqualTo(BrushFamily.SPRING_MODEL)
+        assertThat(BrushFamily.SPRING_MODEL).isNotEqualTo(BrushFamily.LEGACY_SPRING_MODEL)
     }
 
     @Test
     fun toString_returnsExpectedValues() {
-        assertThat(BrushFamily().toString())
+        assertThat(BrushFamily(inputModel = BrushFamily.LEGACY_SPRING_MODEL).toString())
             .isEqualTo(
                 "BrushFamily(coats=[BrushCoat(tips=[BrushTip(scale=(1.0, 1.0), " +
                     "cornerRounding=1.0, slant=0.0, pinch=0.0, rotation=0.0, opacityMultiplier=1.0, " +
                     "particleGapDistanceScale=0.0, particleGapDurationMillis=0, " +
-                    "behaviors=[])], paint=BrushPaint(textureLayers=[]))], uri=null)"
+                    "behaviors=[])], paint=BrushPaint(textureLayers=[]))], uri=null, " +
+                    "inputModel=LegacySpringModel)"
             )
     }
 
     @Test
+    fun inputModelToString_returnsExpectedValues() {
+        assertThat(BrushFamily.LEGACY_SPRING_MODEL.toString()).isEqualTo("LegacySpringModel")
+        assertThat(BrushFamily.SPRING_MODEL.toString()).isEqualTo("SpringModel")
+    }
+
+    @Test
     fun copy_whenSameContents_returnsSameInstance() {
         val customFamily = BrushFamily(customTip, customPaint, customUri)
 
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt
index be6d493..60ac4fa 100644
--- a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRendererTestActivity.kt
@@ -40,6 +40,7 @@
 /** An [Activity] to support [CanvasStrokeRendererTest]. */
 @OptIn(ExperimentalInkCustomBrushApi::class)
 class CanvasStrokeRendererTestActivity : Activity() {
+    @OptIn(ExperimentalInkCustomBrushApi::class)
     private val textureStore = TextureBitmapStore { uri ->
         when (uri) {
             TEXTURE_URI_AIRPLANE_EMOJI -> R.drawable.airplane_emoji
@@ -51,11 +52,13 @@
     }
     private val meshRenderer =
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-            CanvasStrokeRenderer.create(textureStore)
+            @OptIn(ExperimentalInkCustomBrushApi::class) CanvasStrokeRenderer.create(textureStore)
         } else {
             null
         }
-    private val pathRenderer = CanvasStrokeRenderer.create(textureStore, forcePathRendering = true)
+    private val pathRenderer =
+        @OptIn(ExperimentalInkCustomBrushApi::class)
+        CanvasStrokeRenderer.create(textureStore, forcePathRendering = true)
     private val defaultRenderer = CanvasStrokeRenderer.create()
 
     override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt
index 07d6c58..f978e26 100644
--- a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererRobolectricTest.kt
@@ -55,7 +55,7 @@
                     .asImmutable(),
         )
 
-    @OptIn(ExperimentalInkCustomBrushApi::class) private val meshRenderer = CanvasMeshRenderer()
+    private val meshRenderer = @OptIn(ExperimentalInkCustomBrushApi::class) CanvasMeshRenderer()
 
     @Test
     fun canDraw_withRenderableMesh_returnsTrue() {
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt
index 7f68973..a5ef162 100644
--- a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererScreenshotTestActivity.kt
@@ -78,7 +78,7 @@
         // Stroke with no inputs, and therefore an empty [ModeledShape].
         private val emptyStroke = Stroke(brush, ImmutableStrokeInputBatch.EMPTY)
 
-        @OptIn(ExperimentalInkCustomBrushApi::class) private val renderer = CanvasMeshRenderer()
+        private val renderer = @OptIn(ExperimentalInkCustomBrushApi::class) CanvasMeshRenderer()
 
         override fun onDraw(canvas: Canvas) {
             super.onDraw(canvas)
diff --git a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt
index 4e89280..947dc31 100644
--- a/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt
+++ b/ink/ink-rendering/src/androidInstrumentedTest/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasMeshRendererTest.kt
@@ -67,8 +67,9 @@
 
     private val clock = FakeClock()
 
-    @OptIn(ExperimentalInkCustomBrushApi::class)
-    private val meshRenderer = CanvasMeshRenderer(getDurationTimeMillis = clock::currentTimeMillis)
+    private val meshRenderer =
+        @OptIn(ExperimentalInkCustomBrushApi::class)
+        CanvasMeshRenderer(getDurationTimeMillis = clock::currentTimeMillis)
 
     @Test
     fun obtainShaderMetadata_whenCalledTwiceWithSamePackedInstance_returnsCachedValue() {
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt
index dbb3d0a..29b6647 100644
--- a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/CanvasStrokeRenderer.kt
@@ -135,7 +135,34 @@
         @JvmStatic
         public fun create(): CanvasStrokeRenderer {
             @OptIn(ExperimentalInkCustomBrushApi::class)
-            return create(textureStore = TextureBitmapStore { null })
+            return create(TextureBitmapStore { null }, forcePathRendering = false)
+        }
+
+        /**
+         * Create a [CanvasStrokeRenderer] that is appropriate to the device's API version.
+         *
+         * @param textureStore The [TextureBitmapStore] that will be called to retrieve image data
+         *   for drawing textured strokes.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+        @ExperimentalInkCustomBrushApi
+        @JvmStatic
+        public fun create(textureStore: TextureBitmapStore): CanvasStrokeRenderer {
+            @OptIn(ExperimentalInkCustomBrushApi::class)
+            return create(textureStore, forcePathRendering = false)
+        }
+
+        /**
+         * Create a [CanvasStrokeRenderer] that is appropriate to the device's API version.
+         *
+         * @param forcePathRendering Overrides the drawing strategy selected based on API version to
+         *   always draw strokes using [Canvas.drawPath] instead of [Canvas.drawMesh].
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
+        @JvmStatic
+        public fun create(forcePathRendering: Boolean): CanvasStrokeRenderer {
+            @OptIn(ExperimentalInkCustomBrushApi::class)
+            return create(TextureBitmapStore { null }, forcePathRendering)
         }
 
         /**
@@ -148,11 +175,10 @@
          */
         @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // NonPublicApi
         @ExperimentalInkCustomBrushApi
-        @JvmOverloads
         @JvmStatic
         public fun create(
             textureStore: TextureBitmapStore,
-            forcePathRendering: Boolean = false,
+            forcePathRendering: Boolean,
         ): CanvasStrokeRenderer {
             if (!forcePathRendering) return CanvasStrokeUnifiedRenderer(textureStore)
             return CanvasPathRenderer(textureStore)
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt
index 62fc86c..ce04f14 100644
--- a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/canvas/internal/CanvasStrokeUnifiedRenderer.kt
@@ -37,7 +37,7 @@
 
     private val meshRenderer by lazy {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-            CanvasMeshRenderer(textureStore)
+            @OptIn(ExperimentalInkCustomBrushApi::class) CanvasMeshRenderer(textureStore)
         } else {
             null
         }
diff --git a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt
index 77df307..c68fe83 100644
--- a/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt
+++ b/ink/ink-rendering/src/androidMain/kotlin/androidx/ink/rendering/android/view/ViewStrokeRenderer.kt
@@ -110,7 +110,7 @@
      */
     public fun drawWithStrokes(
         canvas: Canvas,
-        block: (scopedCanvas: Canvas, StrokeDrawScope) -> Unit
+        block: (scopedCanvas: Canvas, StrokeDrawScope) -> Unit,
     ) {
         val scope = obtainDrawScope(canvas)
         block(canvas, scope)
diff --git a/settings.gradle b/settings.gradle
index ee1179c..8594d6e 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -749,6 +749,7 @@
 includeProject(":hilt:hilt-work", [BuildType.MAIN])
 includeProject(":hilt:integration-tests:hilt-testapp-viewmodel", "hilt/integration-tests/viewmodelapp", [BuildType.MAIN])
 includeProject(":hilt:integration-tests:hilt-testapp-worker", "hilt/integration-tests/workerapp", [BuildType.MAIN])
+includeProject(":ink:ink-authoring", [BuildType.MAIN])
 includeProject(":ink:ink-brush", [BuildType.MAIN])
 includeProject(":ink:ink-geometry", [BuildType.MAIN])
 includeProject(":ink:ink-nativeloader", [BuildType.MAIN])