Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 579dd0b6 authored by Michał Brzeziński's avatar Michał Brzeziński Committed by Android (Google) Code Review
Browse files

Merge "Easter egg for touchpad gestures tutorial" into main

parents 796ff3c6 ff31d335
Loading
Loading
Loading
Loading
+156 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+3 −2
Original line number Diff line number Diff line
@@ -41,8 +41,9 @@ class TouchpadGestureHandlerTest : SysuiTestCase() {
        TouchpadGestureHandler(
            BackGestureMonitor(
                gestureDistanceThresholdPx = SWIPE_DISTANCE.toInt(),
                gestureStateChangedCallback = { gestureState = it }
            )
                gestureStateChangedCallback = { gestureState = it },
            ),
            EasterEggGestureMonitor {},
        )

    @Test
+34 −7
Original line number Diff line number Diff line
@@ -18,20 +18,25 @@ package com.android.systemui.touchpad.tutorial.ui.composable

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 @@ interface GestureMonitorProvider {
    @Composable
    fun rememberGestureMonitor(
        resources: Resources,
        gestureStateChangedCallback: (GestureState) -> Unit
        gestureStateChangedCallback: (GestureState) -> Unit,
    ): TouchpadGestureMonitor
}

@@ -57,7 +62,7 @@ class DistanceBasedGestureMonitorProvider(
    @Composable
    override fun rememberGestureMonitor(
        resources: Resources,
        gestureStateChangedCallback: (GestureState) -> Unit
        gestureStateChangedCallback: (GestureState) -> Unit,
    ): TouchpadGestureMonitor {
        val distanceThresholdPx =
            resources.getDimensionPixelSize(
@@ -86,17 +91,25 @@ fun GestureTutorialScreen(
) {
    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 @@ fun GestureTutorialScreen(
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 @@ private fun TouchpadGesturesHandlingBox(
                        }
                    }
                )
                .graphicsLayer { rotationZ = rotationAnimation.value }
    ) {
        content()
    }
+151 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+6 −1
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import android.view.MotionEvent
 */
class TouchpadGestureHandler(
    private val gestureMonitor: TouchpadGestureMonitor,
    private val easterEggGestureMonitor: EasterEggGestureMonitor,
) {

    fun onMotionEvent(event: MotionEvent): Boolean {
@@ -36,7 +37,11 @@ class TouchpadGestureHandler(
            event.actionMasked == MotionEvent.ACTION_DOWN &&
                event.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
        return if (isFromTouchpad && !buttonClick) {
            if (isTwoFingerSwipe(event)) {
                easterEggGestureMonitor.processTouchpadEvent(event)
            } else {
                gestureMonitor.processTouchpadEvent(event)
            }
            true
        } else {
            false
Loading