diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/udfps/InteractionEvent.kt b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/InteractionEvent.kt new file mode 100644 index 0000000000000..6e47dadc45450 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/InteractionEvent.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.view.MotionEvent + +/** Interaction event between a finger and the under-display fingerprint sensor (UDFPS). */ +enum class InteractionEvent { + /** + * A finger entered the sensor area. This can originate from either [MotionEvent.ACTION_DOWN] or + * [MotionEvent.ACTION_MOVE]. + */ + DOWN, + + /** + * A finger left the sensor area. This can originate from either [MotionEvent.ACTION_UP] or + * [MotionEvent.ACTION_MOVE]. + */ + UP, + + /** + * The touch reporting has stopped. This corresponds to [MotionEvent.ACTION_CANCEL]. This should + * not be confused with [UP]. If there was a finger on the sensor, it may or may not still be on + * the sensor. + */ + CANCEL, + + /** + * The interaction hasn't changed since the previous event. The can originate from any of + * [MotionEvent.ACTION_DOWN], [MotionEvent.ACTION_MOVE], or [MotionEvent.ACTION_UP] if one of + * these is true: + * - There was previously a finger on the sensor, and that finger is still on the sensor. + * - There was previously no finger on the sensor, and there still isn't. + */ + UNCHANGED, +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/udfps/NormalizedTouchData.kt b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/NormalizedTouchData.kt new file mode 100644 index 0000000000000..f26c732a1b619 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/NormalizedTouchData.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.graphics.RectF +import android.view.MotionEvent +import com.android.systemui.biometrics.UdfpsOverlayParams + +/** Touch data in natural orientation and native resolution. */ +data class NormalizedTouchData( + + /** + * Value obtained from [MotionEvent.getPointerId], or [MotionEvent.INVALID_POINTER_ID] if the ID + * is not available. + */ + val pointerId: Int, + + /** [MotionEvent.getRawX] mapped to natural orientation and native resolution. */ + val x: Float, + + /** [MotionEvent.getRawY] mapped to natural orientation and native resolution. */ + val y: Float, + + /** [MotionEvent.getTouchMinor] mapped to natural orientation and native resolution. */ + val minor: Float, + + /** [MotionEvent.getTouchMajor] mapped to natural orientation and native resolution. */ + val major: Float, + + /** [MotionEvent.getOrientation] mapped to natural orientation. */ + val orientation: Float, + + /** [MotionEvent.getEventTime]. */ + val time: Long, + + /** [MotionEvent.getDownTime]. */ + val gestureStart: Long, +) { + + /** + * [overlayParams] contains the location and dimensions of the sensor area, as well as the scale + * factor and orientation of the overlay. See [UdfpsOverlayParams]. + * + * Returns whether the given pointer is within the sensor's bounding box. + */ + fun isWithinSensor(overlayParams: UdfpsOverlayParams): Boolean { + val r = RectF(overlayParams.sensorBounds).apply { scale(1f / overlayParams.scaleFactor) } + return r.left <= x && r.right >= x && r.top <= y && r.bottom >= y + } +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessor.kt new file mode 100644 index 0000000000000..451b895d95749 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessor.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.graphics.PointF +import android.util.RotationUtils +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.Surface +import com.android.systemui.biometrics.UdfpsOverlayParams +import com.android.systemui.biometrics.udfps.TouchProcessorResult.Failure +import com.android.systemui.biometrics.udfps.TouchProcessorResult.ProcessedTouch + +// TODO(b/259140693): Consider using an object pool of TouchProcessorResult to avoid allocations. +class SinglePointerTouchProcessor : TouchProcessor { + + override fun processTouch( + event: MotionEvent, + previousPointerOnSensorId: Int, + overlayParams: UdfpsOverlayParams, + ): TouchProcessorResult { + + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> + processActionDown(event.preprocess(previousPointerOnSensorId, overlayParams)) + MotionEvent.ACTION_MOVE -> + processActionMove(event.preprocess(previousPointerOnSensorId, overlayParams)) + MotionEvent.ACTION_UP -> + processActionUp(event.preprocess(previousPointerOnSensorId, overlayParams)) + MotionEvent.ACTION_CANCEL -> + processActionCancel(event.preprocess(previousPointerOnSensorId, overlayParams)) + else -> + Failure("Unsupported MotionEvent." + MotionEvent.actionToString(event.actionMasked)) + } + } +} + +private data class PreprocessedTouch( + val data: NormalizedTouchData, + val previousPointerOnSensorId: Int, + val isWithinSensor: Boolean, +) + +private fun processActionDown(touch: PreprocessedTouch): TouchProcessorResult { + return if (touch.isWithinSensor) { + ProcessedTouch(InteractionEvent.DOWN, pointerOnSensorId = touch.data.pointerId, touch.data) + } else { + val event = + if (touch.data.pointerId == touch.previousPointerOnSensorId) { + InteractionEvent.UP + } else { + InteractionEvent.UNCHANGED + } + ProcessedTouch(event, pointerOnSensorId = INVALID_POINTER_ID, touch.data) + } +} + +private fun processActionMove(touch: PreprocessedTouch): TouchProcessorResult { + val hadPointerOnSensor = touch.previousPointerOnSensorId != INVALID_POINTER_ID + val interactionEvent = + when { + touch.isWithinSensor && !hadPointerOnSensor -> InteractionEvent.DOWN + !touch.isWithinSensor && hadPointerOnSensor -> InteractionEvent.UP + else -> InteractionEvent.UNCHANGED + } + val pointerOnSensorId = + when (interactionEvent) { + InteractionEvent.UNCHANGED -> touch.previousPointerOnSensorId + InteractionEvent.DOWN -> touch.data.pointerId + else -> INVALID_POINTER_ID + } + return ProcessedTouch(interactionEvent, pointerOnSensorId, touch.data) +} + +private fun processActionUp(touch: PreprocessedTouch): TouchProcessorResult { + return if (touch.isWithinSensor) { + ProcessedTouch(InteractionEvent.UP, pointerOnSensorId = INVALID_POINTER_ID, touch.data) + } else { + val event = + if (touch.previousPointerOnSensorId != INVALID_POINTER_ID) { + InteractionEvent.UP + } else { + InteractionEvent.UNCHANGED + } + ProcessedTouch(event, pointerOnSensorId = INVALID_POINTER_ID, touch.data) + } +} + +private fun processActionCancel(touch: PreprocessedTouch): TouchProcessorResult { + return ProcessedTouch( + InteractionEvent.CANCEL, + pointerOnSensorId = INVALID_POINTER_ID, + touch.data + ) +} + +/** Returns [PreprocessedTouch], which is an input to all the action-specific functions. */ +private fun MotionEvent.preprocess( + previousPointerOnSensorId: Int, + overlayParams: UdfpsOverlayParams +): PreprocessedTouch { + // TODO(b/253085297): Add multitouch support. pointerIndex can be > 0 for ACTION_MOVE. + val pointerIndex = 0 + val touchData = normalize(pointerIndex, overlayParams) + val isWithinSensor = touchData.isWithinSensor(overlayParams) + return PreprocessedTouch(touchData, previousPointerOnSensorId, isWithinSensor) +} + +/** + * Returns the touch information from the given [MotionEvent] with the relevant fields mapped to + * natural orientation and native resolution. + */ +private fun MotionEvent.normalize( + pointerIndex: Int, + overlayParams: UdfpsOverlayParams +): NormalizedTouchData { + val naturalTouch: PointF = rotateToNaturalOrientation(pointerIndex, overlayParams) + val nativeX = naturalTouch.x / overlayParams.scaleFactor + val nativeY = naturalTouch.y / overlayParams.scaleFactor + val nativeMinor: Float = getTouchMinor(pointerIndex) / overlayParams.scaleFactor + val nativeMajor: Float = getTouchMajor(pointerIndex) / overlayParams.scaleFactor + return NormalizedTouchData( + pointerId = getPointerId(pointerIndex), + x = nativeX, + y = nativeY, + minor = nativeMinor, + major = nativeMajor, + // TODO(b/259311354): touch orientation should be reported relative to Surface.ROTATION_O. + orientation = getOrientation(pointerIndex), + time = eventTime, + gestureStart = downTime, + ) +} + +/** + * Returns the [MotionEvent.getRawX] and [MotionEvent.getRawY] of the given pointer as if the device + * is in the [Surface.ROTATION_0] orientation. + */ +private fun MotionEvent.rotateToNaturalOrientation( + pointerIndex: Int, + overlayParams: UdfpsOverlayParams +): PointF { + val touchPoint = PointF(getRawX(pointerIndex), getRawY(pointerIndex)) + val rot = overlayParams.rotation + if (rot == Surface.ROTATION_90 || rot == Surface.ROTATION_270) { + RotationUtils.rotatePointF( + touchPoint, + RotationUtils.deltaRotation(rot, Surface.ROTATION_0), + overlayParams.logicalDisplayWidth.toFloat(), + overlayParams.logicalDisplayHeight.toFloat() + ) + } + return touchPoint +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessor.kt new file mode 100644 index 0000000000000..ffcebf9cff754 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessor.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.view.MotionEvent +import com.android.systemui.biometrics.UdfpsOverlayParams + +/** + * Determines whether a finger entered or left the area of the under-display fingerprint sensor + * (UDFPS). Maps the touch information from a [MotionEvent] to the orientation and scale independent + * [NormalizedTouchData]. + */ +interface TouchProcessor { + + /** + * [event] touch event to be processed. + * + * [previousPointerOnSensorId] pointerId for the finger that was on the sensor prior to this + * event. See [MotionEvent.getPointerId]. If there was no finger on the sensor, this should be + * set to [MotionEvent.INVALID_POINTER_ID]. + * + * [overlayParams] contains the location and dimensions of the sensor area, as well as the scale + * factor and orientation of the overlay. See [UdfpsOverlayParams]. + * + * Returns [TouchProcessorResult.ProcessedTouch] on success, and [TouchProcessorResult.Failure] + * on failure. + */ + fun processTouch( + event: MotionEvent, + previousPointerOnSensorId: Int, + overlayParams: UdfpsOverlayParams, + ): TouchProcessorResult +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessorResult.kt b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessorResult.kt new file mode 100644 index 0000000000000..be75bb0d38213 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/udfps/TouchProcessorResult.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.view.MotionEvent + +/** Contains all the possible returns types for [TouchProcessor.processTouch] */ +sealed class TouchProcessorResult { + + /** + * [event] whether a finger entered or left the sensor area. See [InteractionEvent]. + * + * [pointerOnSensorId] pointerId fof the finger that's currently on the sensor. See + * [MotionEvent.getPointerId]. If there is no finger on the sensor, the value is set to + * [MotionEvent.INVALID_POINTER_ID]. + * + * [touchData] relevant data from the MotionEvent, mapped to natural orientation and native + * resolution. See [NormalizedTouchData]. + */ + data class ProcessedTouch( + val event: InteractionEvent, + val pointerOnSensorId: Int, + val touchData: NormalizedTouchData + ) : TouchProcessorResult() + + /** [reason] the reason for the failure. */ + data class Failure(val reason: String = "") : TouchProcessorResult() +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/NormalizedTouchDataTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/NormalizedTouchDataTest.kt new file mode 100644 index 0000000000000..d72d803fad9e8 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/NormalizedTouchDataTest.kt @@ -0,0 +1,92 @@ +package com.android.systemui.biometrics.udfps + +import android.graphics.Rect +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.biometrics.UdfpsOverlayParams +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@SmallTest +@RunWith(Parameterized::class) +class NormalizedTouchDataTest(val testCase: TestCase) : SysuiTestCase() { + + @Test + fun isWithinSensor() { + val touchData = TOUCH_DATA.copy(x = testCase.x.toFloat(), y = testCase.y.toFloat()) + val actual = touchData.isWithinSensor(OVERLAY_PARAMS) + + assertThat(actual).isEqualTo(testCase.expected) + } + + data class TestCase(val x: Int, val y: Int, val expected: Boolean) + + companion object { + @Parameters(name = "{0}") + @JvmStatic + fun data(): List = + listOf( + genPositiveTestCases( + validXs = listOf(SENSOR.left, SENSOR.right, SENSOR.centerX()), + validYs = listOf(SENSOR.top, SENSOR.bottom, SENSOR.centerY()) + ), + genNegativeTestCases( + invalidXs = listOf(SENSOR.left - 1, SENSOR.right + 1), + invalidYs = listOf(SENSOR.top - 1, SENSOR.bottom + 1), + validXs = listOf(SENSOR.left, SENSOR.right, SENSOR.centerX()), + validYs = listOf(SENSOR.top, SENSOR.bottom, SENSOR.centerY()) + ) + ) + .flatten() + } +} + +/* Placeholder touch parameters. */ +private const val POINTER_ID = 42 +private const val NATIVE_MINOR = 2.71828f +private const val NATIVE_MAJOR = 3.14f +private const val ORIENTATION = 1.23f +private const val TIME = 12345699L +private const val GESTURE_START = 12345600L + +/* Template [NormalizedTouchData]. */ +private val TOUCH_DATA = + NormalizedTouchData( + POINTER_ID, + x = 0f, + y = 0f, + NATIVE_MINOR, + NATIVE_MAJOR, + ORIENTATION, + TIME, + GESTURE_START + ) + +private val SENSOR = Rect(100 /* left */, 200 /* top */, 300 /* right */, 500 /* bottom */) +private val OVERLAY_PARAMS = UdfpsOverlayParams(sensorBounds = SENSOR) + +private fun genTestCases( + xs: List, + ys: List, + expected: Boolean +): List { + return xs.flatMap { x -> ys.map { y -> NormalizedTouchDataTest.TestCase(x, y, expected) } } +} + +private fun genPositiveTestCases( + validXs: List, + validYs: List, +) = genTestCases(validXs, validYs, expected = true) + +private fun genNegativeTestCases( + invalidXs: List, + invalidYs: List, + validXs: List, + validYs: List, +): List { + return genTestCases(invalidXs, validYs, expected = false) + + genTestCases(validXs, invalidYs, expected = false) +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessorTest.kt new file mode 100644 index 0000000000000..2b66205d7a0d2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/udfps/SinglePointerTouchProcessorTest.kt @@ -0,0 +1,500 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.biometrics.udfps + +import android.graphics.Rect +import android.view.MotionEvent +import android.view.MotionEvent.INVALID_POINTER_ID +import android.view.MotionEvent.PointerProperties +import android.view.Surface +import android.view.Surface.Rotation +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.biometrics.UdfpsOverlayParams +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@SmallTest +@RunWith(Parameterized::class) +class SinglePointerTouchProcessorTest(val testCase: TestCase) : SysuiTestCase() { + private val underTest = SinglePointerTouchProcessor() + + @Test + fun processTouch() { + val actual = + underTest.processTouch( + testCase.event, + testCase.previousPointerOnSensorId, + testCase.overlayParams, + ) + + assertThat(actual).isInstanceOf(testCase.expected.javaClass) + if (actual is TouchProcessorResult.ProcessedTouch) { + assertThat(actual).isEqualTo(testCase.expected) + } + } + + data class TestCase( + val event: MotionEvent, + val previousPointerOnSensorId: Int, + val overlayParams: UdfpsOverlayParams, + val expected: TouchProcessorResult, + ) { + override fun toString(): String { + val expectedOutput = + if (expected is TouchProcessorResult.ProcessedTouch) { + expected.event.toString() + + ", (x: ${expected.touchData.x}, y: ${expected.touchData.y})" + + ", pointerOnSensorId: ${expected.pointerOnSensorId}" + + ", ..." + } else { + TouchProcessorResult.Failure().toString() + } + return "{" + + MotionEvent.actionToString(event.action) + + ", (x: ${event.x}, y: ${event.y})" + + ", scale: ${overlayParams.scaleFactor}" + + ", rotation: " + + Surface.rotationToString(overlayParams.rotation) + + ", previousPointerOnSensorId: $previousPointerOnSensorId" + + ", ...} expected: {$expectedOutput}" + } + } + + companion object { + @Parameters(name = "{0}") + @JvmStatic + fun data(): List = + listOf( + // MotionEvent.ACTION_DOWN + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_DOWN, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.DOWN, + expectedPointerOnSensorId = POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_DOWN, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.DOWN, + expectedPointerOnSensorId = POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_DOWN, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UNCHANGED, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_DOWN, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UP, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + // MotionEvent.ACTION_MOVE + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_MOVE, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.DOWN, + expectedPointerOnSensorId = POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_MOVE, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.UNCHANGED, + expectedPointerOnSensorId = POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_MOVE, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UNCHANGED, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_MOVE, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UP, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + // MotionEvent.ACTION_UP + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_UP, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.UP, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_UP, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.UP, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_UP, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UNCHANGED, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_UP, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.UP, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + // MotionEvent.ACTION_CANCEL + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_CANCEL, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.CANCEL, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_CANCEL, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = true, + expectedInteractionEvent = InteractionEvent.CANCEL, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_CANCEL, + previousPointerOnSensorId = INVALID_POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.CANCEL, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + genPositiveTestCases( + motionEventAction = MotionEvent.ACTION_CANCEL, + previousPointerOnSensorId = POINTER_ID, + isWithinSensor = false, + expectedInteractionEvent = InteractionEvent.CANCEL, + expectedPointerOnSensorId = INVALID_POINTER_ID, + ), + ) + .flatten() + + listOf( + // Unsupported MotionEvent actions. + genTestCasesForUnsupportedAction(MotionEvent.ACTION_POINTER_DOWN), + genTestCasesForUnsupportedAction(MotionEvent.ACTION_POINTER_UP), + genTestCasesForUnsupportedAction(MotionEvent.ACTION_HOVER_ENTER), + genTestCasesForUnsupportedAction(MotionEvent.ACTION_HOVER_MOVE), + genTestCasesForUnsupportedAction(MotionEvent.ACTION_HOVER_EXIT), + ) + .flatten() + } +} + +/* Display dimensions in native resolution and natural orientation. */ +private const val ROTATION_0_NATIVE_DISPLAY_WIDTH = 400 +private const val ROTATION_0_NATIVE_DISPLAY_HEIGHT = 600 + +/* + * ROTATION_0 map: + * _ _ _ _ + * _ _ O _ + * _ _ _ _ + * _ S _ _ + * _ S _ _ + * _ _ _ _ + * + * (_) empty space + * (S) sensor + * (O) touch outside of the sensor + */ +private val ROTATION_0_NATIVE_SENSOR_BOUNDS = + Rect( + 100, /* left */ + 300, /* top */ + 200, /* right */ + 500, /* bottom */ + ) +private val ROTATION_0_INPUTS = + OrientationBasedInputs( + rotation = Surface.ROTATION_0, + nativeXWithinSensor = ROTATION_0_NATIVE_SENSOR_BOUNDS.exactCenterX(), + nativeYWithinSensor = ROTATION_0_NATIVE_SENSOR_BOUNDS.exactCenterY(), + nativeXOutsideSensor = 250f, + nativeYOutsideSensor = 150f, + ) + +/* + * ROTATION_90 map: + * _ _ _ _ _ _ + * _ O _ _ _ _ + * _ _ _ S S _ + * _ _ _ _ _ _ + * + * (_) empty space + * (S) sensor + * (O) touch outside of the sensor + */ +private val ROTATION_90_NATIVE_SENSOR_BOUNDS = + Rect( + 300, /* left */ + 200, /* top */ + 500, /* right */ + 300, /* bottom */ + ) +private val ROTATION_90_INPUTS = + OrientationBasedInputs( + rotation = Surface.ROTATION_90, + nativeXWithinSensor = ROTATION_90_NATIVE_SENSOR_BOUNDS.exactCenterX(), + nativeYWithinSensor = ROTATION_90_NATIVE_SENSOR_BOUNDS.exactCenterY(), + nativeXOutsideSensor = 150f, + nativeYOutsideSensor = 150f, + ) + +/* ROTATION_180 is not supported. It's treated the same as ROTATION_0. */ +private val ROTATION_180_INPUTS = + ROTATION_0_INPUTS.copy( + rotation = Surface.ROTATION_180, + ) + +/* + * ROTATION_270 map: + * _ _ _ _ _ _ + * _ S S _ _ _ + * _ _ _ _ O _ + * _ _ _ _ _ _ + * + * (_) empty space + * (S) sensor + * (O) touch outside of the sensor + */ +private val ROTATION_270_NATIVE_SENSOR_BOUNDS = + Rect( + 100, /* left */ + 100, /* top */ + 300, /* right */ + 200, /* bottom */ + ) +private val ROTATION_270_INPUTS = + OrientationBasedInputs( + rotation = Surface.ROTATION_270, + nativeXWithinSensor = ROTATION_270_NATIVE_SENSOR_BOUNDS.exactCenterX(), + nativeYWithinSensor = ROTATION_270_NATIVE_SENSOR_BOUNDS.exactCenterY(), + nativeXOutsideSensor = 450f, + nativeYOutsideSensor = 250f, + ) + +/* Placeholder touch parameters. */ +private const val POINTER_ID = 42 +private const val NATIVE_MINOR = 2.71828f +private const val NATIVE_MAJOR = 3.14f +private const val ORIENTATION = 1.23f +private const val TIME = 12345699L +private const val GESTURE_START = 12345600L + +/* Template [MotionEvent]. */ +private val MOTION_EVENT = + obtainMotionEvent( + action = 0, + pointerId = POINTER_ID, + x = 0f, + y = 0f, + minor = 0f, + major = 0f, + orientation = ORIENTATION, + time = TIME, + gestureStart = GESTURE_START, + ) + +/* Template [NormalizedTouchData]. */ +private val NORMALIZED_TOUCH_DATA = + NormalizedTouchData( + POINTER_ID, + x = 0f, + y = 0f, + NATIVE_MINOR, + NATIVE_MAJOR, + ORIENTATION, + TIME, + GESTURE_START + ) + +/* + * Contains test inputs that are tied to a particular device orientation. + * + * "native" means in native resolution (not scaled). + */ +private data class OrientationBasedInputs( + @Rotation val rotation: Int, + val nativeXWithinSensor: Float, + val nativeYWithinSensor: Float, + val nativeXOutsideSensor: Float, + val nativeYOutsideSensor: Float, +) { + + fun toOverlayParams(scaleFactor: Float): UdfpsOverlayParams = + UdfpsOverlayParams( + sensorBounds = ROTATION_0_NATIVE_SENSOR_BOUNDS.scaled(scaleFactor), + overlayBounds = ROTATION_0_NATIVE_SENSOR_BOUNDS.scaled(scaleFactor), + naturalDisplayHeight = (ROTATION_0_NATIVE_DISPLAY_HEIGHT * scaleFactor).toInt(), + naturalDisplayWidth = (ROTATION_0_NATIVE_DISPLAY_WIDTH * scaleFactor).toInt(), + scaleFactor = scaleFactor, + rotation = rotation + ) + + fun getNativeX(isWithinSensor: Boolean): Float { + return if (isWithinSensor) nativeXWithinSensor else nativeXOutsideSensor + } + + fun getNativeY(isWithinSensor: Boolean): Float { + return if (isWithinSensor) nativeYWithinSensor else nativeYOutsideSensor + } +} + +private fun genPositiveTestCases( + motionEventAction: Int, + previousPointerOnSensorId: Int, + isWithinSensor: Boolean, + expectedInteractionEvent: InteractionEvent, + expectedPointerOnSensorId: Int +): List { + val scaleFactors = listOf(0.75f, 1f, 1.5f) + val orientations = + listOf( + ROTATION_0_INPUTS, + ROTATION_90_INPUTS, + ROTATION_180_INPUTS, + ROTATION_270_INPUTS, + ) + return scaleFactors.flatMap { scaleFactor -> + orientations.map { orientation -> + val overlayParams = orientation.toOverlayParams(scaleFactor) + val nativeX = orientation.getNativeX(isWithinSensor) + val nativeY = orientation.getNativeY(isWithinSensor) + val event = + MOTION_EVENT.copy( + action = motionEventAction, + x = nativeX * scaleFactor, + y = nativeY * scaleFactor, + minor = NATIVE_MINOR * scaleFactor, + major = NATIVE_MAJOR * scaleFactor, + ) + val expectedTouchData = + NORMALIZED_TOUCH_DATA.copy( + x = ROTATION_0_INPUTS.getNativeX(isWithinSensor), + y = ROTATION_0_INPUTS.getNativeY(isWithinSensor), + ) + val expected = + TouchProcessorResult.ProcessedTouch( + event = expectedInteractionEvent, + pointerOnSensorId = expectedPointerOnSensorId, + touchData = expectedTouchData, + ) + SinglePointerTouchProcessorTest.TestCase( + event = event, + previousPointerOnSensorId = previousPointerOnSensorId, + overlayParams = overlayParams, + expected = expected, + ) + } + } +} + +private fun genTestCasesForUnsupportedAction( + motionEventAction: Int +): List { + val isWithinSensor = true + val previousPointerOnSensorIds = listOf(INVALID_POINTER_ID, POINTER_ID) + return previousPointerOnSensorIds.map { previousPointerOnSensorId -> + val overlayParams = ROTATION_0_INPUTS.toOverlayParams(scaleFactor = 1f) + val nativeX = ROTATION_0_INPUTS.getNativeX(isWithinSensor) + val nativeY = ROTATION_0_INPUTS.getNativeY(isWithinSensor) + val event = + MOTION_EVENT.copy( + action = motionEventAction, + x = nativeX, + y = nativeY, + minor = NATIVE_MINOR, + major = NATIVE_MAJOR, + ) + SinglePointerTouchProcessorTest.TestCase( + event = event, + previousPointerOnSensorId = previousPointerOnSensorId, + overlayParams = overlayParams, + expected = TouchProcessorResult.Failure(), + ) + } +} + +private fun obtainMotionEvent( + action: Int, + pointerId: Int, + x: Float, + y: Float, + minor: Float, + major: Float, + orientation: Float, + time: Long, + gestureStart: Long, +): MotionEvent { + val pp = PointerProperties() + pp.id = pointerId + val pc = MotionEvent.PointerCoords() + pc.x = x + pc.y = y + pc.touchMinor = minor + pc.touchMajor = major + pc.orientation = orientation + return MotionEvent.obtain( + gestureStart /* downTime */, + time /* eventTime */, + action /* action */, + 1 /* pointerCount */, + arrayOf(pp) /* pointerProperties */, + arrayOf(pc) /* pointerCoords */, + 0 /* metaState */, + 0 /* buttonState */, + 1f /* xPrecision */, + 1f /* yPrecision */, + 0 /* deviceId */, + 0 /* edgeFlags */, + 0 /* source */, + 0 /* flags */ + ) +} + +private fun MotionEvent.copy( + action: Int = this.action, + pointerId: Int = this.getPointerId(0), + x: Float = this.rawX, + y: Float = this.rawY, + minor: Float = this.touchMinor, + major: Float = this.touchMajor, + orientation: Float = this.orientation, + time: Long = this.eventTime, + gestureStart: Long = this.downTime, +) = obtainMotionEvent(action, pointerId, x, y, minor, major, orientation, time, gestureStart) + +private fun Rect.scaled(scaleFactor: Float) = Rect(this).apply { scale(scaleFactor) }