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

Commit e8c17453 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

QS long-press effect without coroutines and touch listeners

Setting a touch listener in the QS long-press effect has been a source
of regression. In addition, small increases in the main thread work
during the required input event handling have created other
regressions. This change aims to write the long-press effect in the most
minimal way possible. Here, we only spy on touch events on the tile view
and remove the overhead of coroutine continuations by adding a callback
mechanism.

Test: atest SystemUITests:QSTileViewImplTest
Test: atest SystemUiRobotTests:QSLongPressEffectTest
Bug: 345363816
Flag: com.android.systemui.quick_settings_visual_haptics_longpress
Change-Id: Ic00f25597af977f7d94cf4248c1456d36ac7b69c
parent 68291550
Loading
Loading
Loading
Loading
+53 −38
Original line number Original line Diff line number Diff line
@@ -21,26 +21,35 @@ import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.vibratorHelper
import com.android.systemui.haptics.vibratorHelper
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
import com.android.systemui.keyguard.domain.interactor.keyguardInteractor
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.testScope
import com.android.systemui.qs.qsTileFactory
import com.android.systemui.statusbar.policy.keyguardStateController
import com.android.systemui.testKosmos
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever


@SmallTest
@SmallTest
@RunWith(AndroidJUnit4::class)
@RunWith(AndroidJUnit4::class)
@RunWithLooper(setAsMainLooper = true)
@RunWithLooper(setAsMainLooper = true)
class QSLongPressEffectTest : SysuiTestCase() {
class QSLongPressEffectTest : SysuiTestCase() {


    @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule()
    private val kosmos = testKosmos()
    private val kosmos = testKosmos()
    private val vibratorHelper = kosmos.vibratorHelper
    private val vibratorHelper = kosmos.vibratorHelper
    private val qsTile = kosmos.qsTileFactory.createTile("Test Tile")
    @Mock private lateinit var callback: QSLongPressEffect.Callback


    private val effectDuration = 400
    private val effectDuration = 400
    private val lowTickDuration = 12
    private val lowTickDuration = 12
@@ -54,13 +63,15 @@ class QSLongPressEffectTest : SysuiTestCase() {
            lowTickDuration
            lowTickDuration
        vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_SPIN] = spinDuration
        vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_SPIN] = spinDuration


        kosmos.fakeKeyguardRepository.setKeyguardDismissible(true)
        whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(true)


        longPressEffect =
        longPressEffect =
            QSLongPressEffect(
            QSLongPressEffect(
                vibratorHelper,
                vibratorHelper,
                kosmos.keyguardInteractor,
                kosmos.keyguardStateController,
            )
            )
        longPressEffect.callback = callback
        longPressEffect.qsTile = qsTile
    }
    }


    @Test
    @Test
@@ -106,20 +117,6 @@ class QSLongPressEffectTest : SysuiTestCase() {
            assertEffectDidNotStart()
            assertEffectDidNotStart()
        }
        }


    @Test
    fun onActionUp_whileWaiting_performsClick() =
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
            // GIVEN an action is being collected
            val action by collectLastValue(longPressEffect.actionType)

            // GIVEN an action up occurs
            longPressEffect.handleActionUp()

            // THEN the action to invoke is the click action and the effect does not start
            assertThat(action).isEqualTo(QSLongPressEffect.ActionType.CLICK)
            assertEffectDidNotStart()
        }

    @Test
    @Test
    fun onWaitComplete_whileWaiting_beginsEffect() =
    fun onWaitComplete_whileWaiting_beginsEffect() =
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
@@ -127,8 +124,7 @@ class QSLongPressEffectTest : SysuiTestCase() {
            longPressEffect.handleTimeoutComplete()
            longPressEffect.handleTimeoutComplete()


            // THEN the effect emits the action to start an animator
            // THEN the effect emits the action to start an animator
            val action by collectLastValue(longPressEffect.actionType)
            verify(callback, times(1)).onStartAnimator()
            assertThat(action).isEqualTo(QSLongPressEffect.ActionType.START_ANIMATOR)
        }
        }


    @Test
    @Test
@@ -179,26 +175,28 @@ class QSLongPressEffectTest : SysuiTestCase() {
        }
        }


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


            // THEN the long-press effect completes with a LONG_PRESS
            // THEN the long-press effect completes and the view is called to prepare
            assertEffectCompleted(QSLongPressEffect.ActionType.LONG_PRESS)
            assertEffectCompleted()
            verify(callback, times(1)).onPrepareForLaunch()
        }
        }


    @Test
    @Test
    fun onAnimationComplete_keyguardNotDismissible_effectEndsWithResetAndLongPress() =
    fun onAnimationComplete_keyguardNotDismissible_effectEndsWithReset() =
        testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) {
        testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) {
            // GIVEN that the keyguard is not dismissible
            // GIVEN that the keyguard is not dismissible
            kosmos.fakeKeyguardRepository.setKeyguardDismissible(false)
            whenever(kosmos.keyguardStateController.isUnlocked).thenReturn(false)


            // GIVEN that the animation completes
            // GIVEN that the animation completes
            longPressEffect.handleAnimationComplete()
            longPressEffect.handleAnimationComplete()


            // THEN the long-press effect completes with RESET_AND_LONG_PRESS
            // THEN the long-press effect completes and the properties are called to reset
            assertEffectCompleted(QSLongPressEffect.ActionType.RESET_AND_LONG_PRESS)
            assertEffectCompleted()
            verify(callback, times(1)).onResetProperties()
        }
        }


    @Test
    @Test
@@ -211,8 +209,7 @@ class QSLongPressEffectTest : SysuiTestCase() {
            longPressEffect.handleActionDown()
            longPressEffect.handleActionDown()


            // THEN the effect posts an action to cancel the animator
            // THEN the effect posts an action to cancel the animator
            val action by collectLastValue(longPressEffect.actionType)
            verify(callback, times(1)).onCancelAnimator()
            assertThat(action).isEqualTo(QSLongPressEffect.ActionType.CANCEL_ANIMATOR)
        }
        }


    @Test
    @Test
@@ -238,6 +235,29 @@ class QSLongPressEffectTest : SysuiTestCase() {
            assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
            assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
        }
        }


    @Test
    fun onTileClick_whileWaiting_withQSTile_clicks() =
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
            // GIVEN that a click was detected
            val couldClick = longPressEffect.onTileClick()

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

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

            // GIVEN that a click was detected
            val couldClick = longPressEffect.onTileClick()

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

    private fun testWithScope(initialize: Boolean = true, test: suspend TestScope.() -> Unit) =
    private fun testWithScope(initialize: Boolean = true, test: suspend TestScope.() -> Unit) =
        with(kosmos) {
        with(kosmos) {
            testScope.runTest {
            testScope.runTest {
@@ -300,16 +320,13 @@ class QSLongPressEffectTest : SysuiTestCase() {
     * Asserts that the effect completes by checking that:
     * Asserts that the effect completes by checking that:
     * 1. The final snap haptics are played
     * 1. The final snap haptics are played
     * 2. The internal state goes back to [QSLongPressEffect.State.IDLE]
     * 2. The internal state goes back to [QSLongPressEffect.State.IDLE]
     * 3. The action to perform on the tile is the action given as a parameter
     */
     */
    private fun TestScope.assertEffectCompleted(expectedAction: QSLongPressEffect.ActionType) {
    private fun assertEffectCompleted() {
        val action by collectLastValue(longPressEffect.actionType)
        val snapEffect = LongPressHapticBuilder.createSnapEffect()
        val snapEffect = LongPressHapticBuilder.createSnapEffect()


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


    /**
    /**
@@ -317,10 +334,8 @@ class QSLongPressEffectTest : SysuiTestCase() {
     * 1. The internal state is [QSLongPressEffect.State.RUNNING_BACKWARDS]
     * 1. The internal state is [QSLongPressEffect.State.RUNNING_BACKWARDS]
     * 2. An action to reverse the animator is emitted
     * 2. An action to reverse the animator is emitted
     */
     */
    private fun TestScope.assertEffectReverses() {
    private fun assertEffectReverses() {
        val action by collectLastValue(longPressEffect.actionType)

        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS)
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS)
        assertThat(action).isEqualTo(QSLongPressEffect.ActionType.REVERSE_ANIMATOR)
        verify(callback, times(1)).onReverseAnimator()
    }
    }
}
}
+54 −49
Original line number Original line Diff line number Diff line
@@ -17,21 +17,19 @@
package com.android.systemui.haptics.qs
package com.android.systemui.haptics.qs


import android.os.VibrationEffect
import android.os.VibrationEffect
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting
import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
import com.android.systemui.animation.Expandable
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.statusbar.policy.KeyguardStateController
import javax.inject.Inject
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine


/**
/**
 * A class that handles the long press visuo-haptic effect for a QS tile.
 * A class that handles the long press visuo-haptic effect for a QS tile.
 *
 *
 * The class is also a [View.OnTouchListener] to handle the touch events, clicks and long-press
 * The class can contain references to a [QSTile] and an [Expandable] to perform clicks and
 * gestures of the tile. The class also provides a [State] tha can be used to determine the current
 * long-clicks on the tile. The class also provides a [State] tha can be used to determine the
 * state of the long press effect.
 * current state of the long press effect.
 *
 *
 * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects.
 * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects.
 * @property[effectDuration] The duration of the effect in ms.
 * @property[effectDuration] The duration of the effect in ms.
@@ -41,7 +39,7 @@ class QSLongPressEffect
@Inject
@Inject
constructor(
constructor(
    private val vibratorHelper: VibratorHelper?,
    private val vibratorHelper: VibratorHelper?,
    keyguardInteractor: KeyguardInteractor,
    private val keyguardStateController: KeyguardStateController,
) {
) {


    var effectDuration = 0
    var effectDuration = 0
@@ -51,19 +49,12 @@ constructor(
    var state = State.IDLE
    var state = State.IDLE
        private set
        private set


    /** Flow for view control and action */
    /** Callback object for effect actions */
    private val _postedActionType = MutableStateFlow<ActionType?>(null)
    var callback: Callback? = null
    val actionType: Flow<ActionType?> =

        combine(
    /** The [QSTile] and [Expandable] used to perform a long-click and click actions */
            _postedActionType,
    var qsTile: QSTile? = null
            keyguardInteractor.isKeyguardDismissible,
    var expandable: Expandable? = null
        ) { action, isDismissible ->
            if (!isDismissible && action == ActionType.LONG_PRESS) {
                ActionType.RESET_AND_LONG_PRESS
            } else {
                action
            }
        }


    /** Haptic effects */
    /** Haptic effects */
    private val durations =
    private val durations =
@@ -106,33 +97,24 @@ constructor(
            State.IDLE -> {
            State.IDLE -> {
                setState(State.TIMEOUT_WAIT)
                setState(State.TIMEOUT_WAIT)
            }
            }
            State.RUNNING_BACKWARDS -> _postedActionType.value = ActionType.CANCEL_ANIMATOR
            State.RUNNING_BACKWARDS -> callback?.onCancelAnimator()
            else -> {}
            else -> {}
        }
        }
    }
    }


    fun handleActionUp() {
    fun handleActionUp() {
        when (state) {
        if (state == State.RUNNING_FORWARD) {
            State.TIMEOUT_WAIT -> {
                _postedActionType.value = ActionType.CLICK
                setState(State.IDLE)
            }
            State.RUNNING_FORWARD -> {
                _postedActionType.value = ActionType.REVERSE_ANIMATOR
            setState(State.RUNNING_BACKWARDS)
            setState(State.RUNNING_BACKWARDS)
            }
            callback?.onReverseAnimator()
            else -> {}
        }
        }
    }
    }


    fun handleActionCancel() {
    fun handleActionCancel() {
        when (state) {
        when (state) {
            State.TIMEOUT_WAIT -> {
            State.TIMEOUT_WAIT -> setState(State.IDLE)
                setState(State.IDLE)
            }
            State.RUNNING_FORWARD -> {
            State.RUNNING_FORWARD -> {
                _postedActionType.value = ActionType.REVERSE_ANIMATOR
                setState(State.RUNNING_BACKWARDS)
                setState(State.RUNNING_BACKWARDS)
                callback?.onReverseAnimator()
            }
            }
            else -> {}
            else -> {}
        }
        }
@@ -146,8 +128,15 @@ constructor(
    /** This function is called both when an animator completes or gets cancelled */
    /** This function is called both when an animator completes or gets cancelled */
    fun handleAnimationComplete() {
    fun handleAnimationComplete() {
        if (state == State.RUNNING_FORWARD) {
        if (state == State.RUNNING_FORWARD) {
            setState(State.IDLE)
            vibrate(snapEffect)
            vibrate(snapEffect)
            _postedActionType.value = ActionType.LONG_PRESS
            if (keyguardStateController.isUnlocked) {
                callback?.onPrepareForLaunch()
                qsTile?.longClick(expandable)
            } else {
                callback?.onResetProperties()
                qsTile?.longClick(expandable)
            }
        }
        }
        if (state != State.TIMEOUT_WAIT) {
        if (state != State.TIMEOUT_WAIT) {
            // This will happen if the animator did not finish by being cancelled
            // This will happen if the animator did not finish by being cancelled
@@ -161,12 +150,19 @@ constructor(


    fun handleTimeoutComplete() {
    fun handleTimeoutComplete() {
        if (state == State.TIMEOUT_WAIT) {
        if (state == State.TIMEOUT_WAIT) {
            _postedActionType.value = ActionType.START_ANIMATOR
            callback?.onStartAnimator()
        }
        }
    }
    }


    fun clearActionType() {
    fun onTileClick(): Boolean {
        _postedActionType.value = null
        if (state == State.TIMEOUT_WAIT) {
            setState(State.IDLE)
            qsTile?.let {
                it.click(expandable)
                return true
            }
        }
        return false
    }
    }


    /**
    /**
@@ -200,13 +196,22 @@ constructor(
        RUNNING_BACKWARDS, /* The effect was interrupted and is now running backwards */
        RUNNING_BACKWARDS, /* The effect was interrupted and is now running backwards */
    }
    }


    /* A type of action to perform on the view depending on the effect's state and logic */
    /** Callbacks to notify view and animator actions */
    enum class ActionType {
    interface Callback {
        CLICK,

        LONG_PRESS,
        /** Prepare for an activity launch */
        RESET_AND_LONG_PRESS,
        fun onPrepareForLaunch()
        START_ANIMATOR,

        REVERSE_ANIMATOR,
        /** Reset the tile visual properties */
        CANCEL_ANIMATOR,
        fun onResetProperties()

        /** Start the effect animator */
        fun onStartAnimator()

        /** Reverse the effect animator */
        fun onReverseAnimator()

        /** Cancel the effect animator */
        fun onCancelAnimator()
    }
    }
}
}
+0 −127
Original line number Original line 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.haptics.qs

import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.animation.doOnCancel
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.app.tracing.coroutines.launch
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.qs.tileimpl.QSTileViewImpl
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.flow.filterNotNull

object QSLongPressEffectViewBinder {

    fun bind(
        tile: QSTileViewImpl,
        qsLongPressEffect: QSLongPressEffect?,
        tileSpec: String?,
    ): DisposableHandle? {
        if (qsLongPressEffect == null) return null

        // Set the touch listener as the long-press effect
        setTouchListener(tile, qsLongPressEffect)

        return tile.repeatWhenAttached {
            repeatOnLifecycle(Lifecycle.State.CREATED) {
                // Action to perform
                launch({ "${tileSpec ?: "unknownTileSpec"}#LongPressEffect#action" }) {
                    var effectAnimator: ValueAnimator? = null

                    qsLongPressEffect.actionType.filterNotNull().collect { action ->
                        when (action) {
                            QSLongPressEffect.ActionType.CLICK -> {
                                tile.performClick()
                                qsLongPressEffect.clearActionType()
                            }
                            QSLongPressEffect.ActionType.LONG_PRESS -> {
                                tile.prepareForLaunch()
                                tile.performLongClick()
                                qsLongPressEffect.clearActionType()
                            }
                            QSLongPressEffect.ActionType.RESET_AND_LONG_PRESS -> {
                                tile.resetLongPressEffectProperties()
                                tile.performLongClick()
                                qsLongPressEffect.clearActionType()
                            }
                            QSLongPressEffect.ActionType.START_ANIMATOR -> {
                                if (effectAnimator?.isRunning != true) {
                                    effectAnimator =
                                        ValueAnimator.ofFloat(0f, 1f).apply {
                                            this.duration =
                                                qsLongPressEffect.effectDuration.toLong()
                                            interpolator = AccelerateDecelerateInterpolator()

                                            doOnStart { qsLongPressEffect.handleAnimationStart() }
                                            addUpdateListener {
                                                val value = animatedValue as Float
                                                if (value == 0f) {
                                                    tile.bringToFront()
                                                } else {
                                                    tile.updateLongPressEffectProperties(value)
                                                }
                                            }
                                            doOnEnd { qsLongPressEffect.handleAnimationComplete() }
                                            doOnCancel { qsLongPressEffect.handleAnimationCancel() }
                                            start()
                                        }
                                }
                            }
                            QSLongPressEffect.ActionType.REVERSE_ANIMATOR -> {
                                effectAnimator?.let {
                                    val pausedProgress = it.animatedFraction
                                    qsLongPressEffect.playReverseHaptics(pausedProgress)
                                    it.reverse()
                                }
                            }
                            QSLongPressEffect.ActionType.CANCEL_ANIMATOR -> {
                                tile.resetLongPressEffectProperties()
                                effectAnimator?.cancel()
                            }
                        }
                    }
                }
            }
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    private fun setTouchListener(tile: QSTileViewImpl, longPressEffect: QSLongPressEffect?) {
        tile.setOnTouchListener { _, event ->
            when (event.actionMasked) {
                MotionEvent.ACTION_DOWN -> {
                    tile.postDelayed(
                        { longPressEffect?.handleTimeoutComplete() },
                        ViewConfiguration.getTapTimeout().toLong(),
                    )
                    longPressEffect?.handleActionDown()
                }
                MotionEvent.ACTION_UP -> longPressEffect?.handleActionUp()
                MotionEvent.ACTION_CANCEL -> longPressEffect?.handleActionCancel()
            }
            true
        }
    }
}
Loading