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])