• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.touchpad.tutorial.ui.gesture
18 
19 import android.view.MotionEvent
20 import com.android.systemui.touchpad.tutorial.ui.gesture.EasterEggGestureRecognizer.Companion.CIRCLES_COUNT_THRESHOLD
21 import kotlin.math.abs
22 import kotlin.math.atan2
23 import kotlin.math.pow
24 import kotlin.math.sqrt
25 
26 /**
27  * Monitor recognizing easter egg gesture, that is at least [CIRCLES_COUNT_THRESHOLD] circles
28  * clockwise within one two-fingers gesture. It tries to be on the safer side of not triggering
29  * gesture if we're not sure if full circle was done.
30  */
31 class EasterEggGestureRecognizer : GestureRecognizer {
32 
<lambda>null33     private var gestureStateChangedCallback: (GestureState) -> Unit = {}
34 
35     private var last: Point = Point(0f, 0f)
36     private var cumulativeAngle: Float = 0f
37     private var lastAngle: Float? = null
38     private var circleCount: Int = 0
39 
40     private class Point(val x: Float, val y: Float)
41 
42     private val points = mutableListOf<Point>()
43 
addGestureStateCallbacknull44     override fun addGestureStateCallback(callback: (GestureState) -> Unit) {
45         gestureStateChangedCallback = callback
46     }
47 
clearGestureStateCallbacknull48     override fun clearGestureStateCallback() {
49         gestureStateChangedCallback = {}
50     }
51 
acceptnull52     override fun accept(event: MotionEvent) {
53         if (!isTwoFingerSwipe(event)) return
54         when (event.action) {
55             MotionEvent.ACTION_DOWN -> {
56                 reset()
57                 last = Point(event.x, event.y)
58                 points.add(Point(event.x, event.y))
59             }
60             MotionEvent.ACTION_MOVE -> {
61                 val current = Point(event.x, event.y)
62                 points.add(current)
63 
64                 if (distanceBetween(last, current) > MIN_MOTION_EVENT_DISTANCE_PX) {
65                     val currentAngle = calculateAngle(last, current)
66                     if (lastAngle == null) {
67                         // we can't start calculating angle changes before having calculated first
68                         // angle which serves as a reference point
69                         lastAngle = currentAngle
70                     } else {
71                         val deltaAngle = currentAngle - lastAngle!!
72 
73                         cumulativeAngle += normalizeAngleDelta(deltaAngle)
74                         lastAngle = currentAngle
75                         last = current
76 
77                         val fullCircleCompleted = cumulativeAngle >= 2 * Math.PI
78                         if (fullCircleCompleted) {
79                             cumulativeAngle = 0f
80                             circleCount += 1
81                         }
82                     }
83                 }
84             }
85             MotionEvent.ACTION_UP -> {
86                 // without checking if gesture is circular we can have gesture doing arches back and
87                 // forth that finally reaches full circle angle
88                 if (circleCount >= CIRCLES_COUNT_THRESHOLD && wasGestureCircular(points)) {
89                     gestureStateChangedCallback(GestureState.Finished)
90                 }
91                 reset()
92             }
93             MotionEvent.ACTION_CANCEL -> {
94                 reset()
95             }
96         }
97     }
98 
resetnull99     private fun reset() {
100         cumulativeAngle = 0f
101         lastAngle = null
102         circleCount = 0
103         points.clear()
104     }
105 
normalizeAngleDeltanull106     private fun normalizeAngleDelta(deltaAngle: Float): Float {
107         // Normalize the deltaAngle to [-PI, PI] range
108         val normalizedDelta =
109             if (deltaAngle > Math.PI) {
110                 deltaAngle - (2 * Math.PI).toFloat()
111             } else if (deltaAngle < -Math.PI) {
112                 deltaAngle + (2 * Math.PI).toFloat()
113             } else {
114                 deltaAngle
115             }
116         return normalizedDelta
117     }
118 
wasGestureCircularnull119     private fun wasGestureCircular(points: List<Point>): Boolean {
120         val center =
121             Point(
122                 x = points.map { it.x }.average().toFloat(),
123                 y = points.map { it.y }.average().toFloat(),
124             )
125         val radius = points.map { distanceBetween(it, center) }.average().toFloat()
126         for (point in points) {
127             val distance = distanceBetween(point, center)
128             if (abs(distance - radius) > RADIUS_DEVIATION_TOLERANCE * radius) {
129                 return false
130             }
131         }
132         return true
133     }
134 
distanceBetweennull135     private fun distanceBetween(point: Point, center: Point) =
136         sqrt((point.x - center.x).toDouble().pow(2.0) + (point.y - center.y).toDouble().pow(2.0))
137 
138     private fun calculateAngle(point1: Point, point2: Point): Float {
139         return atan2(point2.y - point1.y, point2.x - point1.x)
140     }
141 
142     companion object {
143         /**
144          * How much we allow any one point to deviate from average radius. In other words it's a
145          * modifier of how difficult is to trigger the gesture. The smaller value the harder it is
146          * to trigger. 0.6f seems quite high but:
147          * 1. this is just extra check after circles were verified with movement angle
148          * 2. it's because of how touchpad events work - they're approximating movement, so doing
149          *    smooth circle is ~impossible. Rounded corners square is probably the best thing that
150          *    user can do
151          */
152         private const val RADIUS_DEVIATION_TOLERANCE: Float = 0.7f
153         private const val CIRCLES_COUNT_THRESHOLD = 3
154 
155         /**
156          * Min distance required between motion events to have angular difference calculated. This
157          * value is a tradeoff between: minimizing the noise and delaying circle recognition (high
158          * value) versus performing calculations very/too often (low value).
159          */
160         private const val MIN_MOTION_EVENT_DISTANCE_PX = 10
161     }
162 }
163