Easter egg for touchpad gestures tutorial
Activated by doing three circles clockwise with two fingers on touchpad.
Fixes: 370496598
Test: EasterEggGestureTest
Test: Open any touchpad gesture tutorial and do three circles using two fingers
Flag: com.android.systemui.shared.new_touchpad_gestures_tutorial
Change-Id: I9bdf8f175e98c719f8c2de313aa26c8f549a560b
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureTest.kt
new file mode 100644
index 0000000..a83ed56
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureTest.kt
@@ -0,0 +1,156 @@
+/*
+ * 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 com.android.systemui.touchpad.tutorial.ui.gesture
+
+import android.view.MotionEvent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.touchpad.tutorial.ui.gesture.MultiFingerGesture.Companion.SWIPE_DISTANCE
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.cos
+import kotlin.math.sin
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class EasterEggGestureTest : SysuiTestCase() {
+
+ private data class Point(val x: Float, val y: Float)
+
+ private var triggered = false
+ private val handler =
+ TouchpadGestureHandler(
+ BackGestureMonitor(
+ gestureDistanceThresholdPx = SWIPE_DISTANCE.toInt(),
+ gestureStateChangedCallback = {},
+ ),
+ EasterEggGestureMonitor(callback = { triggered = true }),
+ )
+
+ @Test
+ fun easterEggTriggeredAfterThreeCircles() {
+ assertStateAfterTwoFingerGesture(
+ gesturePath = generateCircularGesturePoints(circlesCount = 3),
+ wasTriggered = true,
+ )
+ }
+
+ @Test
+ fun easterEggTriggeredAfterThreeImperfectCircles() {
+ assertStateAfterTwoFingerGesture(
+ gesturePath =
+ generateCircularGesturePoints(circlesCount = 3, radiusNoiseFraction = 0.2),
+ wasTriggered = true,
+ )
+ }
+
+ @Test
+ fun easterEggTriggeredAfterFiveCircles() {
+ assertStateAfterTwoFingerGesture(
+ gesturePath = generateCircularGesturePoints(circlesCount = 5),
+ wasTriggered = true,
+ )
+ }
+
+ @Test
+ fun easterEggNotTriggeredAfterTwoCircles() {
+ assertStateAfterTwoFingerGesture(
+ gesturePath = generateCircularGesturePoints(circlesCount = 2),
+ wasTriggered = false,
+ )
+ }
+
+ @Test
+ fun easterEggNotTriggeredAfterVariousSwipes() {
+ val allSwipeGestures =
+ listOf(
+ // two finger gestures
+ TwoFingerGesture.swipeUp(),
+ TwoFingerGesture.swipeDown(),
+ TwoFingerGesture.swipeLeft(),
+ TwoFingerGesture.swipeRight(),
+ // three finger gestures
+ ThreeFingerGesture.swipeUp(),
+ ThreeFingerGesture.swipeDown(),
+ ThreeFingerGesture.swipeLeft(),
+ ThreeFingerGesture.swipeRight(),
+ // four finger gestures
+ FourFingerGesture.swipeUp(),
+ FourFingerGesture.swipeDown(),
+ FourFingerGesture.swipeLeft(),
+ FourFingerGesture.swipeRight(),
+ )
+ allSwipeGestures.forEach { gesture ->
+ assertStateAfterEvents(events = gesture, wasTriggered = false)
+ }
+ }
+
+ private fun assertStateAfterEvents(events: List<MotionEvent>, wasTriggered: Boolean) {
+ events.forEach { handler.onMotionEvent(it) }
+ assertThat(triggered).isEqualTo(wasTriggered)
+ }
+
+ private fun assertStateAfterTwoFingerGesture(gesturePath: List<Point>, wasTriggered: Boolean) {
+ val events = TwoFingerGesture.createEvents { gesturePath.forEach { (x, y) -> move(x, y) } }
+ assertStateAfterEvents(events = events, wasTriggered = wasTriggered)
+ }
+
+ /**
+ * Generates list of points that would make up clockwise circular motion with given [radius].
+ * [circlesCount] determines how many full circles gesture should perform. [radiusNoiseFraction]
+ * can introduce noise to mimic real-world gesture which is not perfect - shape will be still
+ * circular but radius at any given point can be deviate from given radius by
+ * [radiusNoiseFraction].
+ */
+ private fun generateCircularGesturePoints(
+ circlesCount: Int,
+ radiusNoiseFraction: Double? = null,
+ radius: Float = 100f,
+ ): List<Point> {
+ val pointsPerCircle = 50
+ val angleStep = 360 / pointsPerCircle
+ val angleBuffer = 20 // buffer to make sure we're doing a bit more than 360 degree
+ val totalAngle = circlesCount * (360 + angleBuffer)
+ // Because all gestures in tests should start at (DEFAULT_X, DEFAULT_Y) we need to shift
+ // circle center x coordinate by radius
+ val centerX = -radius
+ val centerY = 0f
+
+ val events = mutableListOf<Point>()
+ val randomNoise: (Double) -> Double =
+ if (radiusNoiseFraction == null) {
+ { 0.0 }
+ } else {
+ { radianAngle -> sin(radianAngle * 2) * radiusNoiseFraction }
+ }
+
+ var currentAngle = 0f
+ // as cos(0) == 1 and sin(0) == 0 we start gesture at position of (radius, 0) and go
+ // clockwise - first Y increases and X decreases
+ while (currentAngle < totalAngle) {
+ val radianAngle = Math.toRadians(currentAngle.toDouble())
+ val radiusWithNoise = radius * (1 + randomNoise(radianAngle).toFloat())
+ val x = centerX + radiusWithNoise * cos(radianAngle).toFloat()
+ val y = centerY + radiusWithNoise * sin(radianAngle).toFloat()
+ events.add(Point(x, y))
+ currentAngle += angleStep
+ }
+ return events
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandlerTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandlerTest.kt
index 3a01d4f..466ddbe 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandlerTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandlerTest.kt
@@ -41,8 +41,9 @@
TouchpadGestureHandler(
BackGestureMonitor(
gestureDistanceThresholdPx = SWIPE_DISTANCE.toInt(),
- gestureStateChangedCallback = { gestureState = it }
- )
+ gestureStateChangedCallback = { gestureState = it },
+ ),
+ EasterEggGestureMonitor {},
)
@Test
diff --git a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/composable/GestureTutorialScreen.kt b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/composable/GestureTutorialScreen.kt
index 0ecbf70..e1b89da 100644
--- a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/composable/GestureTutorialScreen.kt
+++ b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/composable/GestureTutorialScreen.kt
@@ -18,20 +18,25 @@
import android.content.res.Resources
import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import com.android.systemui.inputdevice.tutorial.ui.composable.ActionTutorialContent
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialScreenConfig
+import com.android.systemui.touchpad.tutorial.ui.gesture.EasterEggGestureMonitor
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState.FINISHED
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState.IN_PROGRESS
@@ -44,7 +49,7 @@
@Composable
fun rememberGestureMonitor(
resources: Resources,
- gestureStateChangedCallback: (GestureState) -> Unit
+ gestureStateChangedCallback: (GestureState) -> Unit,
): TouchpadGestureMonitor
}
@@ -57,7 +62,7 @@
@Composable
override fun rememberGestureMonitor(
resources: Resources,
- gestureStateChangedCallback: (GestureState) -> Unit
+ gestureStateChangedCallback: (GestureState) -> Unit,
): TouchpadGestureMonitor {
val distanceThresholdPx =
resources.getDimensionPixelSize(
@@ -86,17 +91,25 @@
) {
BackHandler(onBack = onBack)
var gestureState by remember { mutableStateOf(NOT_STARTED) }
+ var easterEggTriggered by remember { mutableStateOf(false) }
val gestureMonitor =
gestureMonitorProvider.rememberGestureMonitor(
resources = LocalContext.current.resources,
- gestureStateChangedCallback = { gestureState = it }
+ gestureStateChangedCallback = { gestureState = it },
)
- val gestureHandler = remember(gestureMonitor) { TouchpadGestureHandler(gestureMonitor) }
- TouchpadGesturesHandlingBox(gestureHandler, gestureState) {
+ val easterEggMonitor = EasterEggGestureMonitor { easterEggTriggered = true }
+ val gestureHandler =
+ remember(gestureMonitor) { TouchpadGestureHandler(gestureMonitor, easterEggMonitor) }
+ TouchpadGesturesHandlingBox(
+ gestureHandler,
+ gestureState,
+ easterEggTriggered,
+ resetEasterEggFlag = { easterEggTriggered = false },
+ ) {
ActionTutorialContent(
gestureState.toTutorialActionState(),
onDoneButtonClicked,
- screenConfig
+ screenConfig,
)
}
}
@@ -105,9 +118,22 @@
private fun TouchpadGesturesHandlingBox(
gestureHandler: TouchpadGestureHandler,
gestureState: GestureState,
+ easterEggTriggered: Boolean,
+ resetEasterEggFlag: () -> Unit,
modifier: Modifier = Modifier,
- content: @Composable BoxScope.() -> Unit
+ content: @Composable BoxScope.() -> Unit,
) {
+ val rotationAnimation = remember { Animatable(0f) }
+ LaunchedEffect(easterEggTriggered) {
+ if (easterEggTriggered || rotationAnimation.isRunning) {
+ rotationAnimation.snapTo(0f)
+ rotationAnimation.animateTo(
+ targetValue = 360f,
+ animationSpec = tween(durationMillis = 2000),
+ )
+ resetEasterEggFlag()
+ }
+ }
Box(
modifier =
modifier
@@ -124,6 +150,7 @@
}
}
)
+ .graphicsLayer { rotationZ = rotationAnimation.value }
) {
content()
}
diff --git a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureMonitor.kt b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureMonitor.kt
new file mode 100644
index 0000000..7483840
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/EasterEggGestureMonitor.kt
@@ -0,0 +1,151 @@
+/*
+ * 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 com.android.systemui.touchpad.tutorial.ui.gesture
+
+import android.view.MotionEvent
+import com.android.systemui.touchpad.tutorial.ui.gesture.EasterEggGestureMonitor.Companion.CIRCLES_COUNT_THRESHOLD
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/**
+ * Monitor recognizing easter egg gesture, that is at least [CIRCLES_COUNT_THRESHOLD] circles
+ * clockwise within one gesture. It tries to be on the safer side of not triggering gesture if we're
+ * not sure if full circle was done.
+ */
+class EasterEggGestureMonitor(private val callback: () -> Unit) {
+
+ private var last: Point = Point(0f, 0f)
+ private var cumulativeAngle: Float = 0f
+ private var lastAngle: Float? = null
+ private var circleCount: Int = 0
+
+ private class Point(val x: Float, val y: Float)
+
+ private val points = mutableListOf<Point>()
+
+ fun processTouchpadEvent(event: MotionEvent) {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ reset()
+ last = Point(event.x, event.y)
+ points.add(Point(event.x, event.y))
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val current = Point(event.x, event.y)
+ points.add(current)
+
+ if (distanceBetween(last, current) > MIN_MOTION_EVENT_DISTANCE_PX) {
+ val currentAngle = calculateAngle(last, current)
+ if (lastAngle == null) {
+ // we can't start calculating angle changes before having calculated first
+ // angle which serves as a reference point
+ lastAngle = currentAngle
+ } else {
+ val deltaAngle = currentAngle - lastAngle!!
+
+ cumulativeAngle += normalizeAngleDelta(deltaAngle)
+ lastAngle = currentAngle
+ last = current
+
+ val fullCircleCompleted = cumulativeAngle >= 2 * Math.PI
+ if (fullCircleCompleted) {
+ cumulativeAngle = 0f
+ circleCount += 1
+ }
+ }
+ }
+ }
+ MotionEvent.ACTION_UP -> {
+ // without checking if gesture is circular we can have gesture doing arches back and
+ // forth that finally reaches full circle angle
+ if (circleCount >= CIRCLES_COUNT_THRESHOLD && wasGestureCircular(points)) {
+ callback()
+ }
+ reset()
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ reset()
+ }
+ }
+ }
+
+ private fun reset() {
+ cumulativeAngle = 0f
+ lastAngle = null
+ circleCount = 0
+ points.clear()
+ }
+
+ private fun normalizeAngleDelta(deltaAngle: Float): Float {
+ // Normalize the deltaAngle to [-PI, PI] range
+ val normalizedDelta =
+ if (deltaAngle > Math.PI) {
+ deltaAngle - (2 * Math.PI).toFloat()
+ } else if (deltaAngle < -Math.PI) {
+ deltaAngle + (2 * Math.PI).toFloat()
+ } else {
+ deltaAngle
+ }
+ return normalizedDelta
+ }
+
+ private fun wasGestureCircular(points: List<Point>): Boolean {
+ val center =
+ Point(
+ x = points.map { it.x }.average().toFloat(),
+ y = points.map { it.y }.average().toFloat(),
+ )
+ val radius = points.map { distanceBetween(it, center) }.average().toFloat()
+ for (point in points) {
+ val distance = distanceBetween(point, center)
+ if (abs(distance - radius) > RADIUS_DEVIATION_TOLERANCE * radius) {
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun distanceBetween(point: Point, center: Point) =
+ sqrt((point.x - center.x).toDouble().pow(2.0) + (point.y - center.y).toDouble().pow(2.0))
+
+ private fun calculateAngle(point1: Point, point2: Point): Float {
+ return atan2(point2.y - point1.y, point2.x - point1.x)
+ }
+
+ companion object {
+ /**
+ * How much we allow any one point to deviate from average radius. In other words it's a
+ * modifier of how difficult is to trigger the gesture. The smaller value the harder it is
+ * to trigger. 0.6f seems quite high but:
+ * 1. this is just extra check after circles were verified with movement angle
+ * 2. it's because of how touchpad events work - they're approximating movement, so doing
+ * smooth circle is ~impossible. Rounded corners square is probably the best thing that
+ * user can do
+ */
+ private const val RADIUS_DEVIATION_TOLERANCE: Float = 0.7f
+ private const val CIRCLES_COUNT_THRESHOLD = 3
+
+ /**
+ * Min distance required between motion events to have angular difference calculated. This
+ * value is a tradeoff between: minimizing the noise and delaying circle recognition (high
+ * value) versus performing calculations very/too often (low value).
+ */
+ private const val MIN_MOTION_EVENT_DISTANCE_PX = 10
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandler.kt b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandler.kt
index bf85b0a..88671d4 100644
--- a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandler.kt
+++ b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureHandler.kt
@@ -25,6 +25,7 @@
*/
class TouchpadGestureHandler(
private val gestureMonitor: TouchpadGestureMonitor,
+ private val easterEggGestureMonitor: EasterEggGestureMonitor,
) {
fun onMotionEvent(event: MotionEvent): Boolean {
@@ -36,7 +37,11 @@
event.actionMasked == MotionEvent.ACTION_DOWN &&
event.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
return if (isFromTouchpad && !buttonClick) {
- gestureMonitor.processTouchpadEvent(event)
+ if (isTwoFingerSwipe(event)) {
+ easterEggGestureMonitor.processTouchpadEvent(event)
+ } else {
+ gestureMonitor.processTouchpadEvent(event)
+ }
true
} else {
false
diff --git a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureMonitor.kt b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureMonitor.kt
index 1d2097d..8774a92 100644
--- a/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureMonitor.kt
+++ b/packages/SystemUI/src/com/android/systemui/touchpad/tutorial/ui/gesture/TouchpadGestureMonitor.kt
@@ -38,3 +38,7 @@
return event.classification == MotionEvent.CLASSIFICATION_MULTI_FINGER_SWIPE &&
event.getAxisValue(MotionEvent.AXIS_GESTURE_SWIPE_FINGER_COUNT) == fingerCount.toFloat()
}
+
+fun isTwoFingerSwipe(event: MotionEvent): Boolean {
+ return event.classification == MotionEvent.CLASSIFICATION_TWO_FINGER_SWIPE
+}