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

Commit 41cb4332 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Adding click and long-click terminal states.

The ending of the QSLongPressEffect in a click or a long-click can now
be identified by two new terminal states. This helps disambiguate the
termination states from the IDLE state and avoids any racing conditions
from allowing clicks in the IDLE state. Allowing clicks in the IDLE
(non-terminal state) enables all clicks performed using the Talkback
service. Properly resetting the state is also handled by both the
QSTileViewImpl and the QSLongPressEffect.

Test: atest SystemUiRoboTests:QSLongPressEffectTest
Flag: com.android.systemui.quick_settings_visual_haptics_longpress
Bug: 348295719
Change-Id: Ib2eb5c718be866eb3a03cc560190df94da6a7a3f
parent dc15b279
Loading
Loading
Loading
Loading
+90 −16
Original line number Diff line number Diff line
@@ -17,10 +17,12 @@
package com.android.systemui.haptics.qs

import android.os.VibrationEffect
import android.service.quicksettings.Tile
import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.falsingManager
import com.android.systemui.haptics.vibratorHelper
import com.android.systemui.kosmos.testScope
import com.android.systemui.qs.qsTileFactory
@@ -69,6 +71,7 @@ class QSLongPressEffectTest : SysuiTestCase() {
            QSLongPressEffect(
                vibratorHelper,
                kosmos.keyguardStateController,
                kosmos.falsingManager,
            )
        longPressEffect.callback = callback
        longPressEffect.qsTile = qsTile
@@ -175,17 +178,17 @@ class QSLongPressEffectTest : SysuiTestCase() {
        }

    @Test
    fun onAnimationComplete_keyguardDismissible_effectCompletes() =
    fun onAnimationComplete_keyguardDismissible_effectEndsInLongClicked() =
        testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) {
            // GIVEN that the animation completes
            longPressEffect.handleAnimationComplete()

            // THEN the long-press effect completes
            assertEffectCompleted()
            // THEN the long-press effect completes with a long-click state
            assertEffectCompleted(QSLongPressEffect.State.LONG_CLICKED)
        }

    @Test
    fun onAnimationComplete_keyguardNotDismissible_effectEndsWithReset() =
    fun onAnimationComplete_keyguardNotDismissible_effectEndsInIdleWithReset() =
        testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) {
            // GIVEN that the keyguard is not dismissible
            whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(false)
@@ -193,19 +196,20 @@ class QSLongPressEffectTest : SysuiTestCase() {
            // GIVEN that the animation completes
            longPressEffect.handleAnimationComplete()

            // THEN the long-press effect completes and the properties are called to reset
            assertEffectCompleted()
            // THEN the long-press effect ends in the idle state and the properties are reset
            assertEffectCompleted(QSLongPressEffect.State.IDLE)
            verify(callback, times(1)).onResetProperties()
        }

    @Test
    fun onAnimationComplete_whenRunningBackwardsFromUp_endsWithFinishedReversing() =
    fun onAnimationComplete_whenRunningBackwardsFromUp_endsWithFinishedReversingAndClick() =
        testWhileInState(QSLongPressEffect.State.RUNNING_BACKWARDS_FROM_UP) {
            // GIVEN that the animation completes
            longPressEffect.handleAnimationComplete()

            // THEN the callback for finished reversing is used.
            // THEN the callback for finished reversing is used and the effect ends with a click.
            verify(callback, times(1)).onEffectFinishedReversing()
            assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.CLICKED)
        }

    @Test
@@ -262,18 +266,88 @@ class QSLongPressEffectTest : SysuiTestCase() {
        }

    @Test
    fun onTileClick_whileWaiting_withoutQSTile_cannotClick() =
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
            // GIVEN that no QSTile has been set
            longPressEffect.qsTile = null

    fun onTileClick_whileIdle_withQSTile_clicks() =
        testWhileInState(QSLongPressEffect.State.IDLE) {
            // GIVEN that a click was detected
            val couldClick = longPressEffect.onTileClick()

            // THEN the click is successful
            assertThat(couldClick).isTrue()
        }

    @Test
    fun onTileClick_whenBouncerIsShowing_ignoresClick() =
        testWhileInState(QSLongPressEffect.State.IDLE) {
            // GIVEN that the bouncer is showing
            whenever(kosmos.keyguardStateController.isPrimaryBouncerShowing).thenReturn(true)

            // WHEN a click is detected by the tile view
            val couldClick = longPressEffect.onTileClick()

            // THEN the click is not successful
            assertThat(couldClick).isFalse()
        }

    @Test
    fun getStateForClick_withUnavailableTile_returnsIdle() {
        // GIVEN an unavailable tile
        qsTile.state?.state = Tile.STATE_UNAVAILABLE

        // WHEN determining the state of a click action
        val clickState = longPressEffect.getStateForClick()

        // THEN the state is IDLE
        assertThat(clickState).isEqualTo(QSLongPressEffect.State.IDLE)
    }

    @Test
    fun getStateForClick_withFalseTapWhenLocked_returnsIdle() {
        // GIVEN an active tile
        qsTile.state?.state = Tile.STATE_ACTIVE

        // GIVEN that the device is locked and a false tap is detected
        whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(false)
        kosmos.falsingManager.setFalseTap(true)

        // WHEN determining the state of a click action
        val clickState = longPressEffect.getStateForClick()

        // THEN the state is IDLE
        assertThat(clickState).isEqualTo(QSLongPressEffect.State.IDLE)
    }

    @Test
    fun getStateForClick_withValidTapAndTile_returnsClicked() {
        // GIVEN an active tile
        qsTile.state?.state = Tile.STATE_ACTIVE

        // GIVEN that the device is locked and a false tap is not detected
        whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(false)
        kosmos.falsingManager.setFalseTap(false)

        // WHEN determining the state of a click action
        val clickState = longPressEffect.getStateForClick()

        // THEN the state is CLICKED
        assertThat(clickState).isEqualTo(QSLongPressEffect.State.CLICKED)
    }

    @Test
    fun getStateForClick_withNullTile_returnsIdle() {
        // GIVEN that the tile is null
        longPressEffect.qsTile = null

        // GIVEN that the device is locked and a false tap is not detected
        whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(false)
        kosmos.falsingManager.setFalseTap(false)

        // WHEN determining the state of a click action
        val clickState = longPressEffect.getStateForClick()

        // THEN the state is IDLE
        assertThat(clickState).isEqualTo(QSLongPressEffect.State.IDLE)
    }

    private fun testWithScope(initialize: Boolean = true, test: suspend TestScope.() -> Unit) =
        with(kosmos) {
            testScope.runTest {
@@ -339,14 +413,14 @@ class QSLongPressEffectTest : SysuiTestCase() {
    /**
     * Asserts that the effect completes by checking that:
     * 1. The final snap haptics are played
     * 2. The internal state goes back to [QSLongPressEffect.State.IDLE]
     * 2. The internal state goes back to specified end state.
     */
    private fun assertEffectCompleted() {
    private fun assertEffectCompleted(endState: QSLongPressEffect.State) {
        val snapEffect = LongPressHapticBuilder.createSnapEffect()

        assertThat(snapEffect).isNotNull()
        assertThat(vibratorHelper.hasVibratedWithEffects(snapEffect!!)).isTrue()
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
        assertThat(longPressEffect.state).isEqualTo(endState)
    }

    /**
+41 −11
Original line number Diff line number Diff line
@@ -17,8 +17,10 @@
package com.android.systemui.haptics.qs

import android.os.VibrationEffect
import android.service.quicksettings.Tile
import androidx.annotation.VisibleForTesting
import com.android.systemui.animation.Expandable
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.statusbar.policy.KeyguardStateController
@@ -40,6 +42,7 @@ class QSLongPressEffect
constructor(
    private val vibratorHelper: VibratorHelper?,
    private val keyguardStateController: KeyguardStateController,
    private val falsingManager: FalsingManager,
) {

    var effectDuration = 0
@@ -130,18 +133,18 @@ constructor(
    fun handleAnimationComplete() {
        when (state) {
            State.RUNNING_FORWARD -> {
                setState(State.IDLE)
                vibrate(snapEffect)
                if (keyguardStateController.isUnlocked) {
                    qsTile?.longClick(expandable)
                    setState(State.LONG_CLICKED)
                } else {
                    callback?.onResetProperties()
                    qsTile?.longClick(expandable)
                    setState(State.IDLE)
                }
                qsTile?.longClick(expandable)
            }
            State.RUNNING_BACKWARDS_FROM_UP -> {
                setState(State.IDLE)
                callback?.onEffectFinishedReversing()
                setState(getStateForClick())
                qsTile?.click(expandable)
            }
            State.RUNNING_BACKWARDS_FROM_CANCEL -> setState(State.IDLE)
@@ -160,14 +163,37 @@ constructor(
    }

    fun onTileClick(): Boolean {
        if (state == State.TIMEOUT_WAIT) {
            setState(State.IDLE)
            qsTile?.let {
                it.click(expandable)
        val isStateClickable = state == State.TIMEOUT_WAIT || state == State.IDLE

        // Ignore View-generated clicks on invalid states or if the bouncer is showing
        if (keyguardStateController.isPrimaryBouncerShowing || !isStateClickable) return false

        setState(getStateForClick())
        qsTile?.click(expandable)
        return true
    }

    /**
     * Get the appropriate state for a click action.
     *
     * In some occasions, the click action will not result in a subsequent action that resets the
     * state upon completion (e.g., a launch transition animation). In these cases, the state needs
     * to be reset before the click is dispatched.
     */
    @VisibleForTesting
    fun getStateForClick(): State {
        val isTileUnavailable = qsTile?.state?.state == Tile.STATE_UNAVAILABLE
        val isFalseTapWhileLocked =
            !keyguardStateController.isUnlocked &&
                falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)
        val handlesLongClick = qsTile?.state?.handlesLongClick == true
        return if (isTileUnavailable || isFalseTapWhileLocked || !handlesLongClick) {
            // The click event will not perform an action that resets the state. Therefore, this is
            // the last opportunity to reset the state back to IDLE.
            State.IDLE
        } else {
            State.CLICKED
        }
        return false
    }

    /**
@@ -194,6 +220,8 @@ constructor(
        return true
    }

    fun resetState() = setState(State.IDLE)

    enum class State {
        IDLE, /* The effect is idle waiting for touch input */
        TIMEOUT_WAIT, /* The effect is waiting for a tap timeout period */
@@ -202,6 +230,8 @@ constructor(
        RUNNING_BACKWARDS_FROM_UP,
        /* The effect was interrupted by an ACTION_CANCEL and is now running backwards */
        RUNNING_BACKWARDS_FROM_CANCEL,
        CLICKED, /* The effect has ended with a click */
        LONG_CLICKED, /* The effect has ended with a long-click */
    }

    /** Callbacks to notify view and animator actions */
+5 −0
Original line number Diff line number Diff line
@@ -386,6 +386,7 @@ constructor(
            // The launch animation of a long-press effect did not reset the long-press effect so
            // we must do it here
            resetLongPressEffectProperties()
            longPressEffect.resetState()
        }
        val actualHeight =
            if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
@@ -771,11 +772,14 @@ constructor(
        lastIconTint = icon.getColor(state)

        // Long-press effects
        longPressEffect?.qsTile?.state?.handlesLongClick = state.handlesLongClick
        if (
            state.handlesLongClick &&
                longPressEffect?.initializeEffect(longPressEffectDuration) == true
        ) {
            showRippleEffect = false
            longPressEffect.qsTile?.state?.state = lastState // Store the tile's state
            longPressEffect.resetState()
            initializeLongPressProperties(measuredHeight, measuredWidth)
        } else {
            // Long-press effects might have been enabled before but the new state does not
@@ -906,6 +910,7 @@ constructor(
    }

    override fun onActivityLaunchAnimationEnd() {
        longPressEffect?.resetState()
        if (longPressEffect != null && !haveLongPressPropertiesBeenReset) {
            resetLongPressEffectProperties()
        }
+2 −1
Original line number Diff line number Diff line
@@ -16,9 +16,10 @@

package com.android.systemui.haptics.qs

import com.android.systemui.classifier.falsingManager
import com.android.systemui.haptics.vibratorHelper
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.statusbar.policy.keyguardStateController

val Kosmos.qsLongPressEffect by
    Kosmos.Fixture { QSLongPressEffect(vibratorHelper, keyguardStateController) }
    Kosmos.Fixture { QSLongPressEffect(vibratorHelper, keyguardStateController, falsingManager) }