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

Commit 095b77fc authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing the QSLongPressEffect for visuo-haptic effects on QS tiles.

The effect triggers for tiles that support long-press actions.

Test: SystemUiRoboTests:QSLongPressEffectTest
Flag: NONE
Bug: 229856884
Change-Id: Ife6b822da9ae980dbce9bf5d14368c3f834f424c
parent b6a44c47
Loading
Loading
Loading
Loading
+332 −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.haptics.qs

import android.os.VibrationEffect
import android.testing.TestableLooper.RunWithLooper
import android.view.MotionEvent
import android.view.View
import androidx.test.core.view.MotionEventBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.AnimatorTestRule
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
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.ArgumentMatchers.any
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule

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

    @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule()
    @Mock private lateinit var vibratorHelper: VibratorHelper
    @Mock private lateinit var testView: View
    @get:Rule val animatorTestRule = AnimatorTestRule(this)
    private val kosmos = testKosmos()

    private val effectDuration = 400
    private val lowTickDuration = 12
    private val spinDuration = 133

    private lateinit var longPressEffect: QSLongPressEffect

    @Before
    fun setup() {
        whenever(
                vibratorHelper.getPrimitiveDurations(
                    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                    VibrationEffect.Composition.PRIMITIVE_SPIN,
                )
            )
            .thenReturn(intArrayOf(lowTickDuration, spinDuration))

        longPressEffect =
            QSLongPressEffect(
                vibratorHelper,
                effectDuration,
            )
    }

    @Test
    fun onActionDown_whileIdle_startsWait() = testWithScope {
        // GIVEN an action down event occurs
        val downEvent = buildMotionEvent(MotionEvent.ACTION_DOWN)
        longPressEffect.onTouch(testView, downEvent)

        // THEN the effect moves to the TIMEOUT_WAIT state
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT)
    }

    @Test
    fun onActionCancel_whileWaiting_goesIdle() = testWhileWaiting {
        // GIVEN an action cancel occurs
        val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL)
        longPressEffect.onTouch(testView, cancelEvent)

        // THEN the effect goes back to idle and does not start
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
        assertEffectDidNotStart()
    }

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

        // GIVEN an action up occurs
        val upEvent = buildMotionEvent(MotionEvent.ACTION_UP)
        longPressEffect.onTouch(testView, upEvent)

        // 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() = testWhileWaiting {
        // GIVEN the pressed timeout is complete
        advanceTimeBy(QSLongPressEffect.PRESSED_TIMEOUT + 10L)

        // THEN the effect starts
        assertEffectStarted()
    }

    @Test
    fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileRunning {
        // GIVEN that the effect is at the middle of its completion (progress of 50%)
        animatorTestRule.advanceTimeBy(effectDuration / 2L)

        // WHEN an action up occurs
        val upEvent = buildMotionEvent(MotionEvent.ACTION_UP)
        longPressEffect.onTouch(testView, upEvent)

        // THEN the effect gets reversed at 50% progress
        assertEffectReverses(0.5f)
    }

    @Test
    fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileRunning {
        // GIVEN that the effect is at the middle of its completion (progress of 50%)
        animatorTestRule.advanceTimeBy(effectDuration / 2L)

        // WHEN an action cancel occurs
        val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL)
        longPressEffect.onTouch(testView, cancelEvent)

        // THEN the effect gets reversed at 50% progress
        assertEffectReverses(0.5f)
    }

    @Test
    fun onAnimationComplete_effectEnds() = testWhileRunning {
        // GIVEN that the animation completes
        animatorTestRule.advanceTimeBy(effectDuration + 10L)

        // THEN the long-press effect completes
        assertEffectCompleted()
    }

    @Test
    fun onActionDown_whileRunningBackwards_resets() = testWhileRunning {
        // GIVEN that the effect is at the middle of its completion (progress of 50%)
        animatorTestRule.advanceTimeBy(effectDuration / 2L)

        // GIVEN an action cancel occurs and the effect gets reversed
        val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL)
        longPressEffect.onTouch(testView, cancelEvent)

        // GIVEN an action down occurs
        val downEvent = buildMotionEvent(MotionEvent.ACTION_DOWN)
        longPressEffect.onTouch(testView, downEvent)

        // THEN the effect resets
        assertEffectResets()
    }

    @Test
    fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileRunning {
        // GIVEN that the effect is at the middle of its completion (progress of 50%)
        animatorTestRule.advanceTimeBy(effectDuration / 2L)

        // GIVEN an action cancel occurs and the effect gets reversed
        val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL)
        longPressEffect.onTouch(testView, cancelEvent)

        // GIVEN that the animation completes after a sufficient amount of time
        animatorTestRule.advanceTimeBy(effectDuration.toLong())

        // THEN the state goes to [QSLongPressEffect.State.IDLE]
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
    }

    private fun buildMotionEvent(action: Int): MotionEvent =
        MotionEventBuilder.newBuilder().setAction(action).build()

    private fun testWithScope(test: suspend TestScope.() -> Unit) =
        with(kosmos) {
            testScope.runTest {
                // GIVEN an effect with a testing scope
                longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))

                // THEN run the test
                test()
            }
        }

    private fun testWhileWaiting(test: suspend TestScope.() -> Unit) =
        with(kosmos) {
            testScope.runTest {
                // GIVEN an effect with a testing scope
                longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))

                // GIVEN the TIMEOUT_WAIT state is entered
                val downEvent =
                    MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_DOWN).build()
                longPressEffect.onTouch(testView, downEvent)

                // THEN run the test
                test()
            }
        }

    private fun testWhileRunning(test: suspend TestScope.() -> Unit) =
        with(kosmos) {
            testScope.runTest {
                // GIVEN an effect with a testing scope
                longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))

                // GIVEN the down event that enters the TIMEOUT_WAIT state
                val downEvent =
                    MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_DOWN).build()
                longPressEffect.onTouch(testView, downEvent)

                // GIVEN that the timeout completes and the effect starts
                advanceTimeBy(QSLongPressEffect.PRESSED_TIMEOUT + 10L)

                // THEN run the test
                test()
            }
        }

    /**
     * Asserts that the effect started by checking that:
     * 1. The effect progress is 0f
     * 2. Initial hint haptics are played
     * 3. The internal state is [QSLongPressEffect.State.RUNNING_FORWARD]
     */
    private fun TestScope.assertEffectStarted() {
        val effectProgress by collectLastValue(longPressEffect.effectProgress)
        val longPressHint =
            LongPressHapticBuilder.createLongPressHint(
                lowTickDuration,
                spinDuration,
                effectDuration,
            )

        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD)
        assertThat(effectProgress).isEqualTo(0f)
        assertThat(longPressHint).isNotNull()
        verify(vibratorHelper).vibrate(longPressHint!!)
    }

    /**
     * Asserts that the effect did not start by checking that:
     * 1. No effect progress is emitted
     * 2. No haptics are played
     * 3. The internal state is not [QSLongPressEffect.State.RUNNING_BACKWARDS] or
     *    [QSLongPressEffect.State.RUNNING_FORWARD]
     */
    private fun TestScope.assertEffectDidNotStart() {
        val effectProgress by collectLastValue(longPressEffect.effectProgress)

        assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD)
        assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS)
        assertThat(effectProgress).isNull()
        verify(vibratorHelper, never()).vibrate(any(/* type= */ VibrationEffect::class.java))
    }

    /**
     * Asserts that the effect completes by checking that:
     * 1. The progress is null
     * 2. The final snap haptics are played
     * 3. The internal state goes back to [QSLongPressEffect.State.IDLE]
     * 4. The action to perform on the tile is the long-press action
     */
    private fun TestScope.assertEffectCompleted() {
        val action by collectLastValue(longPressEffect.actionType)
        val effectProgress by collectLastValue(longPressEffect.effectProgress)
        val snapEffect = LongPressHapticBuilder.createSnapEffect()

        assertThat(effectProgress).isNull()
        assertThat(snapEffect).isNotNull()
        verify(vibratorHelper).vibrate(snapEffect!!)
        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE)
        assertThat(action).isEqualTo(QSLongPressEffect.ActionType.LONG_PRESS)
    }

    /**
     * Assert that the effect gets reverted by checking that:
     * 1. The internal state is [QSLongPressEffect.State.RUNNING_BACKWARDS]
     * 2. The reverse haptics plays at the point where the animation was paused
     */
    private fun assertEffectReverses(pausedProgress: Float) {
        val reverseHaptics =
            LongPressHapticBuilder.createReversedEffect(
                pausedProgress,
                lowTickDuration,
                effectDuration,
            )

        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS)
        assertThat(reverseHaptics).isNotNull()
        verify(vibratorHelper).vibrate(reverseHaptics!!)
    }

    /**
     * Asserts that the effect resets by checking that:
     * 1. The effect progress resets to 0
     * 2. The internal state goes back to [QSLongPressEffect.State.TIMEOUT_WAIT]
     */
    private fun TestScope.assertEffectResets() {
        val effectProgress by collectLastValue(longPressEffect.effectProgress)
        assertThat(effectProgress).isEqualTo(0f)

        assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT)
    }
}
+115 −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.haptics.qs

import android.os.VibrationEffect
import android.util.Log
import kotlin.math.max

object LongPressHapticBuilder {

    const val INVALID_DURATION = 0 /* in ms */

    private const val TAG = "LongPressHapticBuilder"
    private const val SPIN_SCALE = 0.2f
    private const val CLICK_SCALE = 0.5f
    private const val LOW_TICK_SCALE = 0.08f
    private const val WARMUP_TIME = 75 /* in ms */
    private const val DAMPING_TIME = 24 /* in ms */

    /** Create the signal that indicates that a long-press action is available. */
    fun createLongPressHint(
        lowTickDuration: Int,
        spinDuration: Int,
        effectDuration: Int
    ): VibrationEffect? {
        if (lowTickDuration == 0 || spinDuration == 0) {
            Log.d(
                TAG,
                "The LOW_TICK and/or SPIN primitives are not supported. No signal created.",
            )
            return null
        }
        if (effectDuration < WARMUP_TIME + spinDuration + DAMPING_TIME) {
            Log.d(
                TAG,
                "Cannot fit long-press hint signal in the effect duration. No signal created",
            )
            return null
        }

        val nLowTicks = WARMUP_TIME / lowTickDuration
        val rampDownLowTicks = DAMPING_TIME / lowTickDuration
        val composition = VibrationEffect.startComposition()

        // Warmup low ticks
        repeat(nLowTicks) {
            composition.addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                LOW_TICK_SCALE,
                0,
            )
        }

        // Spin effect
        composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, SPIN_SCALE, 0)

        // Damping low ticks
        repeat(rampDownLowTicks) { i ->
            composition.addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                LOW_TICK_SCALE / (i + 1),
                0,
            )
        }

        return composition.compose()
    }

    /** Create a "snapping" effect that triggers at the end of a long-press gesture */
    fun createSnapEffect(): VibrationEffect? =
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, CLICK_SCALE, 0)
            .compose()

    /** Creates a signal that indicates the reversal of the long-press animation. */
    fun createReversedEffect(
        pausedProgress: Float,
        lowTickDuration: Int,
        effectDuration: Int,
    ): VibrationEffect? {
        val duration = pausedProgress * effectDuration
        if (duration == 0f) return null

        if (lowTickDuration == 0) {
            Log.d(TAG, "Cannot play reverse haptics because LOW_TICK is not supported")
            return null
        }

        val nLowTicks = (duration / lowTickDuration).toInt()
        if (nLowTicks == 0) return null

        val composition = VibrationEffect.startComposition()
        var scale: Float
        val step = LOW_TICK_SCALE / nLowTicks
        repeat(nLowTicks) { i ->
            scale = max(LOW_TICK_SCALE - step * i, 0f)
            composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, scale, 0)
        }
        return composition.compose()
    }
}
+237 −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.haptics.qs

import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.os.VibrationEffect
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.VisibleForTesting
import androidx.core.animation.doOnCancel
import androidx.core.animation.doOnEnd
import androidx.core.animation.doOnStart
import com.android.systemui.statusbar.VibratorHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/**
 * 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] that 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.
 */
class QSLongPressEffect(
    private val vibratorHelper: VibratorHelper?,
    private val effectDuration: Int,
) : View.OnTouchListener {

    /** Current state */
    var state = State.IDLE
        @VisibleForTesting set

    /** Flows for view control and action */
    private val _effectProgress = MutableStateFlow<Float?>(null)
    val effectProgress = _effectProgress.asStateFlow()

    private val _actionType = MutableStateFlow<ActionType?>(null)
    val actionType = _actionType.asStateFlow()

    /** Haptic effects */
    private val durations =
        vibratorHelper?.getPrimitiveDurations(
            VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
            VibrationEffect.Composition.PRIMITIVE_SPIN
        )

    private val longPressHint =
        LongPressHapticBuilder.createLongPressHint(
            durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION,
            durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION,
            effectDuration
        )

    private val snapEffect = LongPressHapticBuilder.createSnapEffect()

    /* A coroutine scope and a timer job that waits for the pressedTimeout */
    var scope: CoroutineScope? = null
    private var waitJob: Job? = null

    private val effectAnimator =
        ValueAnimator.ofFloat(0f, 1f).apply {
            duration = effectDuration.toLong()
            interpolator = AccelerateDecelerateInterpolator()

            doOnStart { handleAnimationStart() }
            addUpdateListener { _effectProgress.value = animatedValue as Float }
            doOnEnd { handleAnimationComplete() }
            doOnCancel { handleAnimationCancel() }
        }

    private fun reverse() {
        val pausedProgress = effectAnimator.animatedFraction
        val effect =
            LongPressHapticBuilder.createReversedEffect(
                pausedProgress,
                durations?.get(0) ?: 0,
                effectDuration,
            )
        vibratorHelper?.cancel()
        vibrate(effect)
        effectAnimator.reverse()
    }

    private fun vibrate(effect: VibrationEffect?) {
        if (vibratorHelper != null && effect != null) {
            vibratorHelper.vibrate(effect)
        }
    }

    /**
     * Handle relevant touch events for the operation of a Tile.
     *
     * A click action is performed following the relevant logic that originates from the
     * [MotionEvent.ACTION_UP] event depending on the current state.
     */
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouch(view: View?, event: MotionEvent?): Boolean {
        when (event?.actionMasked) {
            MotionEvent.ACTION_DOWN -> handleActionDown()
            MotionEvent.ACTION_UP -> handleActionUp()
            MotionEvent.ACTION_CANCEL -> handleActionCancel()
        }
        return true
    }

    private fun handleActionDown() {
        when (state) {
            State.IDLE -> {
                startPressedTimeoutWait()
                state = State.TIMEOUT_WAIT
            }
            State.RUNNING_BACKWARDS -> effectAnimator.cancel()
            else -> {}
        }
    }

    private fun startPressedTimeoutWait() {
        waitJob =
            scope?.launch {
                try {
                    delay(PRESSED_TIMEOUT)
                    handleTimeoutComplete()
                } catch (_: CancellationException) {
                    state = State.IDLE
                }
            }
    }

    private fun handleActionUp() {
        when (state) {
            State.TIMEOUT_WAIT -> {
                waitJob?.cancel()
                _actionType.value = ActionType.CLICK
                state = State.IDLE
            }
            State.RUNNING_FORWARD -> {
                reverse()
                state = State.RUNNING_BACKWARDS
            }
            else -> {}
        }
    }

    private fun handleActionCancel() {
        when (state) {
            State.TIMEOUT_WAIT -> {
                waitJob?.cancel()
                state = State.IDLE
            }
            State.RUNNING_FORWARD -> {
                reverse()
                state = State.RUNNING_BACKWARDS
            }
            else -> {}
        }
    }

    private fun handleAnimationStart() {
        vibrate(longPressHint)
        state = State.RUNNING_FORWARD
    }

    /** This function is called both when an animator completes or gets cancelled */
    private fun handleAnimationComplete() {
        if (state == State.RUNNING_FORWARD) {
            vibrate(snapEffect)
            _actionType.value = ActionType.LONG_PRESS
            _effectProgress.value = null
        }
        if (state != State.TIMEOUT_WAIT) {
            // This will happen if the animator did not finish by being cancelled
            state = State.IDLE
        }
    }

    private fun handleAnimationCancel() {
        _effectProgress.value = 0f
        startPressedTimeoutWait()
        state = State.TIMEOUT_WAIT
    }

    private fun handleTimeoutComplete() {
        if (state == State.TIMEOUT_WAIT && !effectAnimator.isRunning) {
            effectAnimator.start()
        }
    }

    fun clearActionType() {
        _actionType.value = null
    }

    enum class State {
        IDLE, /* The effect is idle waiting for touch input */
        TIMEOUT_WAIT, /* The effect is waiting for a [PRESSED_TIMEOUT] period */
        RUNNING_FORWARD, /* The effect is running normally */
        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,
    }

    companion object {
        /**
         * A timeout to let the tile resolve if it is being swiped/scrolled. Since QS tiles are
         * inside a scrollable container, they will be considered pressed only after a tap timeout.
         */
        val PRESSED_TIMEOUT = ViewConfiguration.getTapTimeout().toLong() + 20L
    }
}