Add prediction unit tests
Adding some unit tests to ensure that prediction works as expected.
Bug: 232941452
Test: gradlew :input:input-motionprediction:test
Change-Id: I469739735d08f0a4f1eddbc871703333d9d0d782
diff --git a/input/input-motionprediction/build.gradle b/input/input-motionprediction/build.gradle
index 08f5074..67426d2 100644
--- a/input/input-motionprediction/build.gradle
+++ b/input/input-motionprediction/build.gradle
@@ -19,6 +19,7 @@
plugins {
id("AndroidXPlugin")
id("com.android.library")
+ id("org.jetbrains.kotlin.android")
}
dependencies {
@@ -26,11 +27,17 @@
implementation("androidx.core:core:1.10.1")
- androidTestImplementation(libs.testExtJunit)
- androidTestImplementation(libs.testCore)
- androidTestImplementation(libs.testRunner)
- androidTestImplementation(libs.testRules)
- androidTestImplementation(libs.espressoCore, excludes.espresso)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.testCore)
+ testImplementation(libs.testExtJunit)
+ testImplementation(libs.testRules)
+ testImplementation(libs.testRunner)
+ testImplementation(libs.truth)
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinCoroutinesTest)
+ testImplementation(libs.kotlinTest)
+ testImplementation(libs.kotlinReflect)
+
}
android {
diff --git a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
index f091db8..f6841f7d 100644
--- a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
+++ b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/MultiPointerPredictor.java
@@ -59,11 +59,13 @@
int actionIndex = event.getActionIndex();
int pointerId = event.getPointerId(actionIndex);
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
- SinglePointerPredictor predictor = new SinglePointerPredictor();
+ SinglePointerPredictor predictor = new SinglePointerPredictor(
+ pointerId,
+ event.getToolType(actionIndex)
+ );
if (mReportRateMs > 0) {
predictor.setReportRate(mReportRateMs);
}
- predictor.initStrokePrediction(pointerId, event.getToolType(actionIndex));
predictor.onTouchEvent(event);
mPredictorMap.put(pointerId, predictor);
} else if (action == MotionEvent.ACTION_UP) {
diff --git a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
index ca64ac2..90eddf7 100644
--- a/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
+++ b/input/input-motionprediction/src/main/java/androidx/input/motionprediction/kalman/SinglePointerPredictor.java
@@ -82,10 +82,10 @@
private final DVector2 mJank = new DVector2();
/* pointer of the gesture that requires prediction */
- private int mPointerId = 0;
+ private int mPointerId;
/* tool type of the gesture that requires prediction */
- private int mToolType = MotionEvent.TOOL_TYPE_UNKNOWN;
+ private int mToolType;
private double mPressure = 0;
private double mLastOrientation = 0;
@@ -99,13 +99,7 @@
* achieving close-to-zero latency, prediction errors can be more visible and the target should
* be reduced to 20ms.
*/
- public SinglePointerPredictor() {
- mKalman.reset();
- mPrevEventTime = 0;
- mDownEventTime = 0;
- }
-
- void initStrokePrediction(int pointerId, int toolType) {
+ public SinglePointerPredictor(int pointerId, int toolType) {
mKalman.reset();
mPrevEventTime = 0;
mDownEventTime = 0;
diff --git a/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt
new file mode 100644
index 0000000..d7662eb
--- /dev/null
+++ b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/MotionEventGenerator.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.input.motionprediction
+
+import android.view.MotionEvent
+import androidx.test.core.view.MotionEventBuilder
+
+class MotionEventGenerator(val xGenerator: (Long) -> Float, val yGenerator: (Long) -> Float) {
+ private val downEventTime: Long = 0
+ private var currentEventTime: Long = downEventTime
+ private val startX = 500f
+ private val startY = 500f
+ private var sentDown = false
+
+ fun next(): MotionEvent {
+ val motionEventBuilder = MotionEventBuilder.newBuilder()
+ .setEventTime(currentEventTime)
+ .setDownTime(downEventTime)
+ .setActionIndex(0)
+
+ if (sentDown) {
+ motionEventBuilder.setAction(MotionEvent.ACTION_MOVE)
+ } else {
+ motionEventBuilder.setAction(MotionEvent.ACTION_DOWN)
+ sentDown = true
+ }
+
+ val pointerProperties = MotionEvent.PointerProperties()
+ pointerProperties.id = 0
+ pointerProperties.toolType = MotionEvent.TOOL_TYPE_STYLUS
+
+ val coords = MotionEvent.PointerCoords()
+ coords.x = startX + xGenerator(currentEventTime - downEventTime)
+ coords.y = startY + yGenerator(currentEventTime - downEventTime)
+ coords.pressure = 1f
+
+ motionEventBuilder.setPointer(pointerProperties, coords)
+
+ currentEventTime += MOTIONEVENT_RATE_MS
+ return motionEventBuilder.build()
+ }
+
+ fun getRateMs(): Long {
+ return MOTIONEVENT_RATE_MS
+ }
+}
+
+const val MOTIONEVENT_RATE_MS: Long = 5
diff --git a/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt
new file mode 100644
index 0000000..f4a45a0
--- /dev/null
+++ b/input/input-motionprediction/src/test/kotlin/androidx/input/motionprediction/kalman/SinglePointerPredictorTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.input.motionprediction.kalman
+
+import android.view.MotionEvent
+import androidx.input.motionprediction.MotionEventGenerator
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.pow
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class SinglePointerPredictorTest {
+
+ @Test
+ fun simplePrediction() {
+ val generators = arrayOf(
+ // Constant
+ { _: Long -> 0f },
+ // Velocity
+ { delta: Long -> delta.toFloat() },
+ { delta: Long -> -delta.toFloat() },
+ { delta: Long -> 2 * delta.toFloat() },
+ // Acceleration
+ { delta: Long -> delta.toFloat().pow(2) / 4 },
+ { delta: Long -> -delta.toFloat().pow(2) / 4 },
+ { delta: Long -> delta.toFloat().pow(2) / 2 },
+ // Acceleration & velocity
+ { delta: Long -> delta.toFloat() + delta.toFloat().pow(2) / 4 },
+ { delta: Long -> -delta.toFloat() - delta.toFloat().pow(2) / 4 }
+ )
+ for ((xIndex, xGenerator) in generators.withIndex()) {
+ for ((yIndex, yGenerator) in generators.withIndex()) {
+ if (xIndex == 0 && yIndex == 0) {
+ // Predictions won't be generated in this case
+ continue
+ }
+ val predictor = constructPredictor()
+ val generator = MotionEventGenerator(xGenerator, yGenerator)
+ for (i in 1..INITIAL_FEED) {
+ predictor.onTouchEvent(generator.next())
+ }
+ for (i in 1..PREDICT_LENGTH) {
+ val predicted = predictor.predict(generator.getRateMs().toInt())!!
+ val nextEvent = generator.next()
+ assertThat(predicted.eventTime).isEqualTo(nextEvent.eventTime)
+ assertThat(predicted.x).isWithin(0.5f).of(nextEvent.x)
+ assertThat(predicted.y).isWithin(0.5f).of(nextEvent.y)
+
+ predictor.onTouchEvent(nextEvent)
+ }
+ }
+ }
+ }
+}
+
+private fun constructPredictor(): SinglePointerPredictor = SinglePointerPredictor(
+ 0,
+ MotionEvent.TOOL_TYPE_STYLUS
+)
+
+private const val INITIAL_FEED = 20
+private const val PREDICT_LENGTH = 10
diff --git a/input/input-motionprediction/src/test/resources/robolectric.properties b/input/input-motionprediction/src/test/resources/robolectric.properties
new file mode 100644
index 0000000..5566c22e
--- /dev/null
+++ b/input/input-motionprediction/src/test/resources/robolectric.properties
@@ -0,0 +1,3 @@
+# Robolectric currently doesn't support API 34, so we have to explicitly specify 33 as the target
+# sdk for now. Remove when no longer necessary.
+sdk=33
\ No newline at end of file