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 Diff line number Diff line
@@ -21,26 +21,35 @@ 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.coroutines.collectLastValue
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.qs.qsTileFactory
import com.android.systemui.statusbar.policy.keyguardStateController
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
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
@RunWith(AndroidJUnit4::class)
@RunWithLooper(setAsMainLooper = true)
class QSLongPressEffectTest : SysuiTestCase() {

    @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule()
    private val kosmos = testKosmos()
    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 lowTickDuration = 12
@@ -54,13 +63,15 @@ class QSLongPressEffectTest : SysuiTestCase() {
            lowTickDuration
        vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_SPIN] = spinDuration

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

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

    @Test
@@ -106,20 +117,6 @@ class QSLongPressEffectTest : SysuiTestCase() {
            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
    fun onWaitComplete_whileWaiting_beginsEffect() =
        testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) {
@@ -127,8 +124,7 @@ class QSLongPressEffectTest : SysuiTestCase() {
            longPressEffect.handleTimeoutComplete()

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

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

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

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

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

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

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

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

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

    @Test
@@ -238,6 +235,29 @@ class QSLongPressEffectTest : SysuiTestCase() {
            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) =
        with(kosmos) {
            testScope.runTest {
@@ -300,16 +320,13 @@ 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]
     * 3. The action to perform on the tile is the action given as a parameter
     */
    private fun TestScope.assertEffectCompleted(expectedAction: QSLongPressEffect.ActionType) {
        val action by collectLastValue(longPressEffect.actionType)
    private fun assertEffectCompleted() {
        val snapEffect = LongPressHapticBuilder.createSnapEffect()

        assertThat(snapEffect).isNotNull()
        assertThat(vibratorHelper.hasVibratedWithEffects(snapEffect!!)).isTrue()
        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]
     * 2. An action to reverse the animator is emitted
     */
    private fun TestScope.assertEffectReverses() {
        val action by collectLastValue(longPressEffect.actionType)

    private fun assertEffectReverses() {
        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 Diff line number Diff line
@@ -17,21 +17,19 @@
package com.android.systemui.haptics.qs

import android.os.VibrationEffect
import android.view.View
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.policy.KeyguardStateController
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.
 *
 * The class is also a [View.OnTouchListener] to handle the touch events, clicks and long-press
 * gestures of the tile. The class also provides a [State] tha can be used to determine the current
 * state of the long press effect.
 * The class can contain references to a [QSTile] and an [Expandable] to perform clicks and
 * long-clicks on the tile. The class also provides a [State] tha can be used to determine the
 * current state of the long press effect.
 *
 * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects.
 * @property[effectDuration] The duration of the effect in ms.
@@ -41,7 +39,7 @@ class QSLongPressEffect
@Inject
constructor(
    private val vibratorHelper: VibratorHelper?,
    keyguardInteractor: KeyguardInteractor,
    private val keyguardStateController: KeyguardStateController,
) {

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

    /** Flow for view control and action */
    private val _postedActionType = MutableStateFlow<ActionType?>(null)
    val actionType: Flow<ActionType?> =
        combine(
            _postedActionType,
            keyguardInteractor.isKeyguardDismissible,
        ) { action, isDismissible ->
            if (!isDismissible && action == ActionType.LONG_PRESS) {
                ActionType.RESET_AND_LONG_PRESS
            } else {
                action
            }
        }
    /** Callback object for effect actions */
    var callback: Callback? = null

    /** The [QSTile] and [Expandable] used to perform a long-click and click actions */
    var qsTile: QSTile? = null
    var expandable: Expandable? = null

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

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

    fun handleActionCancel() {
        when (state) {
            State.TIMEOUT_WAIT -> {
                setState(State.IDLE)
            }
            State.TIMEOUT_WAIT -> setState(State.IDLE)
            State.RUNNING_FORWARD -> {
                _postedActionType.value = ActionType.REVERSE_ANIMATOR
                setState(State.RUNNING_BACKWARDS)
                callback?.onReverseAnimator()
            }
            else -> {}
        }
@@ -146,8 +128,15 @@ constructor(
    /** This function is called both when an animator completes or gets cancelled */
    fun handleAnimationComplete() {
        if (state == State.RUNNING_FORWARD) {
            setState(State.IDLE)
            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) {
            // This will happen if the animator did not finish by being cancelled
@@ -161,12 +150,19 @@ constructor(

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

    fun clearActionType() {
        _postedActionType.value = null
    fun onTileClick(): Boolean {
        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 */
    }

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

        /** Prepare for an activity launch */
        fun onPrepareForLaunch()

        /** Reset the tile visual properties */
        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 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
        }
    }
}
+276 −184

File changed.

Preview size limit exceeded, changes collapsed.

+75 −53

File changed.

Preview size limit exceeded, changes collapsed.

Loading