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