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
+}