Loading packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt +146 −181 Original line number Diff line number Diff line Loading @@ -21,7 +21,6 @@ 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.animation.AnimatorTestRule import com.android.systemui.coroutines.collectLastValue import com.android.systemui.haptics.vibratorHelper import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository Loading @@ -32,19 +31,14 @@ 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.junit.MockitoJUnit import org.mockito.junit.MockitoRule @SmallTest @RunWith(AndroidJUnit4::class) @RunWithLooper(setAsMainLooper = true) class QSLongPressEffectTest : SysuiTestCase() { @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule() @get:Rule val animatorTestRule = AnimatorTestRule(this) private val kosmos = testKosmos() private val vibratorHelper = kosmos.vibratorHelper Loading @@ -67,58 +61,28 @@ class QSLongPressEffectTest : SysuiTestCase() { vibratorHelper, kosmos.keyguardInteractor, ) longPressEffect.initializeEffect(effectDuration) } @Test fun onReset_whileIdle_resetsEffect() = testWithScope { // GIVEN a call to reset longPressEffect.resetEffect() // THEN the effect remains idle and has not been initialized val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onReset_whileRunning_resetsEffect() = testWhileRunning { // GIVEN a call to reset longPressEffect.resetEffect() // THEN the effect remains idle and has not been initialized val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onInitialize_withNegativeDuration_doesNotInitialize() = testWithScope { // GIVEN an effect that has reset longPressEffect.resetEffect() fun onInitialize_withNegativeDuration_doesNotInitialize() = testWithScope(false) { // WHEN attempting to initialize with a negative duration val couldInitialize = longPressEffect.initializeEffect(-1) // THEN the effect can't initialized and remains reset val state by collectLastValue(longPressEffect.state) assertThat(couldInitialize).isFalse() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onInitialize_withPositiveDuration_initializes() = testWithScope { // GIVEN an effect that has reset longPressEffect.resetEffect() // WHEN attempting to initialize with a positive duration val couldInitialize = longPressEffect.initializeEffect(effectDuration) // THEN the effect is initialized val state by collectLastValue(longPressEffect.state) assertThat(couldInitialize).isTrue() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isTrue() } Loading @@ -128,23 +92,23 @@ class QSLongPressEffectTest : SysuiTestCase() { longPressEffect.handleActionDown() // THEN the effect moves to the TIMEOUT_WAIT state val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } @Test fun onActionCancel_whileWaiting_goesIdle() = testWhileWaiting { fun onActionCancel_whileWaiting_goesIdle() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN an action cancel occurs longPressEffect.handleActionCancel() // THEN the effect goes back to idle and does not start val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertEffectDidNotStart() } @Test fun onActionUp_whileWaiting_performsClick() = testWhileWaiting { fun onActionUp_whileWaiting_performsClick() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN an action is being collected val action by collectLastValue(longPressEffect.actionType) Loading @@ -157,42 +121,68 @@ class QSLongPressEffectTest : SysuiTestCase() { } @Test fun onWaitComplete_whileWaiting_beginsEffect() = testWhileWaiting { fun onWaitComplete_whileWaiting_beginsEffect() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN the pressed timeout is complete longPressEffect.handleTimeoutComplete() // THEN the effect starts assertEffectStarted() // THEN the effect emits the action to start an animator val action by collectLastValue(longPressEffect.actionType) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.START_ANIMATOR) } @Test fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onAnimationStart_whileWaiting_effectBegins() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN that the animator starts longPressEffect.handleAnimationStart() // WHEN an action up occurs // THEN the effect begins assertEffectStarted() } @Test fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN an action up occurs longPressEffect.handleActionUp() // THEN the effect gets reversed at 50% progress assertEffectReverses(0.5f) // THEN the effect reverses assertEffectReverses() } @Test fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onPlayReverseHaptics_reverseHapticsArePlayed() = testWithScope { // GIVEN a call to play reverse haptics at the effect midpoint val progress = 0.5f longPressEffect.playReverseHaptics(progress) // THEN the expected texture is played val reverseHaptics = LongPressHapticBuilder.createReversedEffect( progress, lowTickDuration, effectDuration, ) assertThat(reverseHaptics).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(reverseHaptics!!)).isTrue() } @Test fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // WHEN an action cancel occurs longPressEffect.handleActionCancel() // THEN the effect gets reversed at 50% progress assertEffectReverses(0.5f) // THEN the effect gets reversed assertEffectReverses() } @Test fun onAnimationComplete_keyguardDismissible_effectEndsWithLongPress() = testWhileRunning { fun onAnimationComplete_keyguardDismissible_effectEndsWithLongPress() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the animation completes animatorTestRule.advanceTimeBy(effectDuration + 10L) longPressEffect.handleAnimationComplete() // THEN the long-press effect completes with a LONG_PRESS assertEffectCompleted(QSLongPressEffect.ActionType.LONG_PRESS) Loading @@ -200,68 +190,76 @@ class QSLongPressEffectTest : SysuiTestCase() { @Test fun onAnimationComplete_keyguardNotDismissible_effectEndsWithResetAndLongPress() = testWhileRunning { testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the keyguard is not dismissible kosmos.fakeKeyguardRepository.setKeyguardDismissible(false) // GIVEN that the animation completes animatorTestRule.advanceTimeBy(effectDuration + 10L) longPressEffect.handleAnimationComplete() // THEN the long-press effect completes with RESET_AND_LONG_PRESS assertEffectCompleted(QSLongPressEffect.ActionType.RESET_AND_LONG_PRESS) } @Test fun onActionDown_whileRunningBackwards_resets() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onActionDown_whileRunningBackwards_cancels() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN an action cancel occurs and the effect gets reversed longPressEffect.handleActionCancel() // GIVEN an action down occurs longPressEffect.handleActionDown() // THEN the effect resets assertEffectResets() // THEN the effect posts an action to cancel the animator val action by collectLastValue(longPressEffect.actionType) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.CANCEL_ANIMATOR) } @Test fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onAnimatorCancel_effectGoesBackToWait() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the animator was cancelled longPressEffect.handleAnimationCancel() // THEN the state goes to the timeout wait assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } @Test fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileInState(QSLongPressEffect.State.RUNNING_BACKWARDS) { // GIVEN an action cancel occurs and the effect gets reversed longPressEffect.handleActionCancel() // GIVEN that the animation completes after a sufficient amount of time animatorTestRule.advanceTimeBy(effectDuration.toLong()) // GIVEN that the animation completes longPressEffect.handleAnimationComplete() // THEN the state goes to [QSLongPressEffect.State.IDLE] val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) } private fun testWithScope(test: suspend TestScope.() -> Unit) = with(kosmos) { testScope.runTest { test() } } private fun testWhileWaiting(test: suspend TestScope.() -> Unit) = private fun testWithScope(initialize: Boolean = true, test: suspend TestScope.() -> Unit) = with(kosmos) { testScope.runTest { // GIVEN the TIMEOUT_WAIT state is entered longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) // THEN run the test if (initialize) { longPressEffect.initializeEffect(effectDuration) } test() } } private fun testWhileRunning(test: suspend TestScope.() -> Unit) = private fun testWhileInState( state: QSLongPressEffect.State, initialize: Boolean = true, test: suspend TestScope.() -> Unit, ) = with(kosmos) { testScope.runTest { // GIVEN that the effect starts after the tap timeout is complete longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) longPressEffect.handleTimeoutComplete() if (initialize) { longPressEffect.initializeEffect(effectDuration) } // GIVEN a state longPressEffect.setState(state) // THEN run the test test() Loading @@ -270,13 +268,10 @@ class QSLongPressEffectTest : SysuiTestCase() { /** * 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] * 1. Initial hint haptics are played * 2. The internal state is [QSLongPressEffect.State.RUNNING_FORWARD] */ private fun TestScope.assertEffectStarted() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) private fun assertEffectStarted() { val longPressHint = LongPressHapticBuilder.createLongPressHint( lowTickDuration, Loading @@ -284,78 +279,48 @@ class QSLongPressEffectTest : SysuiTestCase() { effectDuration, ) assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(effectProgress).isEqualTo(0f) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(longPressHint).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(longPressHint!!)).isTrue() } /** * 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 * 1. No haptics are played * 2. The internal state is not [QSLongPressEffect.State.RUNNING_BACKWARDS] or * [QSLongPressEffect.State.RUNNING_FORWARD] */ private fun TestScope.assertEffectDidNotStart() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(effectProgress).isNull() private fun assertEffectDidNotStart() { assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(vibratorHelper.totalVibrations).isEqualTo(0) } /** * 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 action given as a parameter * 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) val effectProgress by collectLastValue(longPressEffect.effectProgress) val snapEffect = LongPressHapticBuilder.createSnapEffect() val state by collectLastValue(longPressEffect.state) assertThat(effectProgress).isNull() assertThat(snapEffect).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(snapEffect!!)).isTrue() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(action).isEqualTo(expectedAction) } /** * 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 TestScope.assertEffectReverses(pausedProgress: Float) { val reverseHaptics = LongPressHapticBuilder.createReversedEffect( pausedProgress, lowTickDuration, effectDuration, ) val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(reverseHaptics).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(reverseHaptics!!)).isTrue() } /** * 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] * 2. An action to reverse the animator is emitted */ private fun TestScope.assertEffectResets() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) private fun TestScope.assertEffectReverses() { val action by collectLastValue(longPressEffect.actionType) assertThat(effectProgress).isNull() assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.REVERSE_ANIMATOR) } } packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt +40 −71 Original line number Diff line number Diff line Loading @@ -16,20 +16,14 @@ package com.android.systemui.haptics.qs import android.animation.ValueAnimator import android.os.VibrationEffect import android.view.View 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.keyguard.domain.interactor.KeyguardInteractor import com.android.systemui.statusbar.VibratorHelper import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine /** Loading @@ -50,17 +44,14 @@ constructor( keyguardInteractor: KeyguardInteractor, ) { private var effectDuration = 0 var effectDuration = 0 private set /** Current state */ private var _state = MutableStateFlow(State.IDLE) val state = _state.asStateFlow() var state = State.IDLE private set /** Flows for view control and action */ private val _effectProgress = MutableStateFlow<Float?>(null) val effectProgress = _effectProgress.asStateFlow() // Actions to perform /** Flow for view control and action */ private val _postedActionType = MutableStateFlow<ActionType?>(null) val actionType: Flow<ActionType?> = combine( Loading @@ -85,19 +76,15 @@ constructor( private val snapEffect = LongPressHapticBuilder.createSnapEffect() private var effectAnimator: ValueAnimator? = null val hasInitialized: Boolean get() = longPressHint != null && effectAnimator != null get() = longPressHint != null @VisibleForTesting fun setState(state: State) { _state.value = state fun setState(newState: State) { state = newState } private fun reverse() { effectAnimator?.let { val pausedProgress = it.animatedFraction fun playReverseHaptics(pausedProgress: Float) { val effect = LongPressHapticBuilder.createReversedEffect( pausedProgress, Loading @@ -106,8 +93,6 @@ constructor( ) vibratorHelper?.cancel() vibrate(effect) it.reverse() } } private fun vibrate(effect: VibrationEffect?) { Loading @@ -117,23 +102,23 @@ constructor( } fun handleActionDown() { when (_state.value) { when (state) { State.IDLE -> { setState(State.TIMEOUT_WAIT) } State.RUNNING_BACKWARDS -> effectAnimator?.cancel() State.RUNNING_BACKWARDS -> _postedActionType.value = ActionType.CANCEL_ANIMATOR else -> {} } } fun handleActionUp() { when (_state.value) { when (state) { State.TIMEOUT_WAIT -> { _postedActionType.value = ActionType.CLICK setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() _postedActionType.value = ActionType.REVERSE_ANIMATOR setState(State.RUNNING_BACKWARDS) } else -> {} Loading @@ -141,44 +126,42 @@ constructor( } fun handleActionCancel() { when (_state.value) { when (state) { State.TIMEOUT_WAIT -> { setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() _postedActionType.value = ActionType.REVERSE_ANIMATOR setState(State.RUNNING_BACKWARDS) } else -> {} } } private fun handleAnimationStart() { fun handleAnimationStart() { vibrate(longPressHint) setState(State.RUNNING_FORWARD) } /** This function is called both when an animator completes or gets cancelled */ private fun handleAnimationComplete() { if (_state.value == State.RUNNING_FORWARD) { fun handleAnimationComplete() { if (state == State.RUNNING_FORWARD) { vibrate(snapEffect) _postedActionType.value = ActionType.LONG_PRESS _effectProgress.value = null } if (_state.value != State.TIMEOUT_WAIT) { if (state != State.TIMEOUT_WAIT) { // This will happen if the animator did not finish by being cancelled setState(State.IDLE) } } private fun handleAnimationCancel() { _effectProgress.value = null fun handleAnimationCancel() { setState(State.TIMEOUT_WAIT) } fun handleTimeoutComplete() { if (_state.value == State.TIMEOUT_WAIT && effectAnimator?.isRunning == false) { effectAnimator?.start() if (state == State.TIMEOUT_WAIT) { _postedActionType.value = ActionType.START_ANIMATOR } } Loading @@ -186,18 +169,6 @@ constructor( _postedActionType.value = null } /** Reset the effect by going back to a default [IDLE] state */ fun resetEffect() { if (effectAnimator?.isRunning == true) { effectAnimator?.cancel() } longPressHint = null effectAnimator = null _effectProgress.value = null _postedActionType.value = null setState(State.IDLE) } /** * Reset the effect with a new effect duration. * Loading @@ -205,27 +176,21 @@ constructor( * @return true if the effect initialized correctly */ fun initializeEffect(duration: Int): Boolean { // The effect can't reset if it is running // The effect can't initialize with a negative duration if (duration <= 0) return false resetEffect() effectDuration = duration effectAnimator = ValueAnimator.ofFloat(0f, 1f).apply { this.duration = effectDuration.toLong() interpolator = AccelerateDecelerateInterpolator() // There is no need to re-initialize if the duration has not changed if (duration == effectDuration) return true doOnStart { handleAnimationStart() } addUpdateListener { _effectProgress.value = animatedValue as Float } doOnEnd { handleAnimationComplete() } doOnCancel { handleAnimationCancel() } } effectDuration = duration longPressHint = LongPressHapticBuilder.createLongPressHint( durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION, durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION, effectDuration ) _postedActionType.value = ActionType.INITIALIZE_ANIMATOR setState(State.IDLE) return true } Loading @@ -241,5 +206,9 @@ constructor( CLICK, LONG_PRESS, RESET_AND_LONG_PRESS, START_ANIMATOR, REVERSE_ANIMATOR, CANCEL_ANIMATOR, INITIALIZE_ANIMATOR, } } packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt +52 −16 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt +146 −181 Original line number Diff line number Diff line Loading @@ -21,7 +21,6 @@ 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.animation.AnimatorTestRule import com.android.systemui.coroutines.collectLastValue import com.android.systemui.haptics.vibratorHelper import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository Loading @@ -32,19 +31,14 @@ 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.junit.MockitoJUnit import org.mockito.junit.MockitoRule @SmallTest @RunWith(AndroidJUnit4::class) @RunWithLooper(setAsMainLooper = true) class QSLongPressEffectTest : SysuiTestCase() { @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule() @get:Rule val animatorTestRule = AnimatorTestRule(this) private val kosmos = testKosmos() private val vibratorHelper = kosmos.vibratorHelper Loading @@ -67,58 +61,28 @@ class QSLongPressEffectTest : SysuiTestCase() { vibratorHelper, kosmos.keyguardInteractor, ) longPressEffect.initializeEffect(effectDuration) } @Test fun onReset_whileIdle_resetsEffect() = testWithScope { // GIVEN a call to reset longPressEffect.resetEffect() // THEN the effect remains idle and has not been initialized val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onReset_whileRunning_resetsEffect() = testWhileRunning { // GIVEN a call to reset longPressEffect.resetEffect() // THEN the effect remains idle and has not been initialized val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onInitialize_withNegativeDuration_doesNotInitialize() = testWithScope { // GIVEN an effect that has reset longPressEffect.resetEffect() fun onInitialize_withNegativeDuration_doesNotInitialize() = testWithScope(false) { // WHEN attempting to initialize with a negative duration val couldInitialize = longPressEffect.initializeEffect(-1) // THEN the effect can't initialized and remains reset val state by collectLastValue(longPressEffect.state) assertThat(couldInitialize).isFalse() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isFalse() } @Test fun onInitialize_withPositiveDuration_initializes() = testWithScope { // GIVEN an effect that has reset longPressEffect.resetEffect() // WHEN attempting to initialize with a positive duration val couldInitialize = longPressEffect.initializeEffect(effectDuration) // THEN the effect is initialized val state by collectLastValue(longPressEffect.state) assertThat(couldInitialize).isTrue() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.hasInitialized).isTrue() } Loading @@ -128,23 +92,23 @@ class QSLongPressEffectTest : SysuiTestCase() { longPressEffect.handleActionDown() // THEN the effect moves to the TIMEOUT_WAIT state val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } @Test fun onActionCancel_whileWaiting_goesIdle() = testWhileWaiting { fun onActionCancel_whileWaiting_goesIdle() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN an action cancel occurs longPressEffect.handleActionCancel() // THEN the effect goes back to idle and does not start val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertEffectDidNotStart() } @Test fun onActionUp_whileWaiting_performsClick() = testWhileWaiting { fun onActionUp_whileWaiting_performsClick() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN an action is being collected val action by collectLastValue(longPressEffect.actionType) Loading @@ -157,42 +121,68 @@ class QSLongPressEffectTest : SysuiTestCase() { } @Test fun onWaitComplete_whileWaiting_beginsEffect() = testWhileWaiting { fun onWaitComplete_whileWaiting_beginsEffect() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN the pressed timeout is complete longPressEffect.handleTimeoutComplete() // THEN the effect starts assertEffectStarted() // THEN the effect emits the action to start an animator val action by collectLastValue(longPressEffect.actionType) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.START_ANIMATOR) } @Test fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onAnimationStart_whileWaiting_effectBegins() = testWhileInState(QSLongPressEffect.State.TIMEOUT_WAIT) { // GIVEN that the animator starts longPressEffect.handleAnimationStart() // WHEN an action up occurs // THEN the effect begins assertEffectStarted() } @Test fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN an action up occurs longPressEffect.handleActionUp() // THEN the effect gets reversed at 50% progress assertEffectReverses(0.5f) // THEN the effect reverses assertEffectReverses() } @Test fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onPlayReverseHaptics_reverseHapticsArePlayed() = testWithScope { // GIVEN a call to play reverse haptics at the effect midpoint val progress = 0.5f longPressEffect.playReverseHaptics(progress) // THEN the expected texture is played val reverseHaptics = LongPressHapticBuilder.createReversedEffect( progress, lowTickDuration, effectDuration, ) assertThat(reverseHaptics).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(reverseHaptics!!)).isTrue() } @Test fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // WHEN an action cancel occurs longPressEffect.handleActionCancel() // THEN the effect gets reversed at 50% progress assertEffectReverses(0.5f) // THEN the effect gets reversed assertEffectReverses() } @Test fun onAnimationComplete_keyguardDismissible_effectEndsWithLongPress() = testWhileRunning { fun onAnimationComplete_keyguardDismissible_effectEndsWithLongPress() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the animation completes animatorTestRule.advanceTimeBy(effectDuration + 10L) longPressEffect.handleAnimationComplete() // THEN the long-press effect completes with a LONG_PRESS assertEffectCompleted(QSLongPressEffect.ActionType.LONG_PRESS) Loading @@ -200,68 +190,76 @@ class QSLongPressEffectTest : SysuiTestCase() { @Test fun onAnimationComplete_keyguardNotDismissible_effectEndsWithResetAndLongPress() = testWhileRunning { testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the keyguard is not dismissible kosmos.fakeKeyguardRepository.setKeyguardDismissible(false) // GIVEN that the animation completes animatorTestRule.advanceTimeBy(effectDuration + 10L) longPressEffect.handleAnimationComplete() // THEN the long-press effect completes with RESET_AND_LONG_PRESS assertEffectCompleted(QSLongPressEffect.ActionType.RESET_AND_LONG_PRESS) } @Test fun onActionDown_whileRunningBackwards_resets() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onActionDown_whileRunningBackwards_cancels() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN an action cancel occurs and the effect gets reversed longPressEffect.handleActionCancel() // GIVEN an action down occurs longPressEffect.handleActionDown() // THEN the effect resets assertEffectResets() // THEN the effect posts an action to cancel the animator val action by collectLastValue(longPressEffect.actionType) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.CANCEL_ANIMATOR) } @Test fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileRunning { // GIVEN that the effect is at the middle of its completion (progress of 50%) animatorTestRule.advanceTimeBy(effectDuration / 2L) fun onAnimatorCancel_effectGoesBackToWait() = testWhileInState(QSLongPressEffect.State.RUNNING_FORWARD) { // GIVEN that the animator was cancelled longPressEffect.handleAnimationCancel() // THEN the state goes to the timeout wait assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } @Test fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileInState(QSLongPressEffect.State.RUNNING_BACKWARDS) { // GIVEN an action cancel occurs and the effect gets reversed longPressEffect.handleActionCancel() // GIVEN that the animation completes after a sufficient amount of time animatorTestRule.advanceTimeBy(effectDuration.toLong()) // GIVEN that the animation completes longPressEffect.handleAnimationComplete() // THEN the state goes to [QSLongPressEffect.State.IDLE] val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) } private fun testWithScope(test: suspend TestScope.() -> Unit) = with(kosmos) { testScope.runTest { test() } } private fun testWhileWaiting(test: suspend TestScope.() -> Unit) = private fun testWithScope(initialize: Boolean = true, test: suspend TestScope.() -> Unit) = with(kosmos) { testScope.runTest { // GIVEN the TIMEOUT_WAIT state is entered longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) // THEN run the test if (initialize) { longPressEffect.initializeEffect(effectDuration) } test() } } private fun testWhileRunning(test: suspend TestScope.() -> Unit) = private fun testWhileInState( state: QSLongPressEffect.State, initialize: Boolean = true, test: suspend TestScope.() -> Unit, ) = with(kosmos) { testScope.runTest { // GIVEN that the effect starts after the tap timeout is complete longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) longPressEffect.handleTimeoutComplete() if (initialize) { longPressEffect.initializeEffect(effectDuration) } // GIVEN a state longPressEffect.setState(state) // THEN run the test test() Loading @@ -270,13 +268,10 @@ class QSLongPressEffectTest : SysuiTestCase() { /** * 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] * 1. Initial hint haptics are played * 2. The internal state is [QSLongPressEffect.State.RUNNING_FORWARD] */ private fun TestScope.assertEffectStarted() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) private fun assertEffectStarted() { val longPressHint = LongPressHapticBuilder.createLongPressHint( lowTickDuration, Loading @@ -284,78 +279,48 @@ class QSLongPressEffectTest : SysuiTestCase() { effectDuration, ) assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(effectProgress).isEqualTo(0f) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(longPressHint).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(longPressHint!!)).isTrue() } /** * 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 * 1. No haptics are played * 2. The internal state is not [QSLongPressEffect.State.RUNNING_BACKWARDS] or * [QSLongPressEffect.State.RUNNING_FORWARD] */ private fun TestScope.assertEffectDidNotStart() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(effectProgress).isNull() private fun assertEffectDidNotStart() { assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(vibratorHelper.totalVibrations).isEqualTo(0) } /** * 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 action given as a parameter * 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) val effectProgress by collectLastValue(longPressEffect.effectProgress) val snapEffect = LongPressHapticBuilder.createSnapEffect() val state by collectLastValue(longPressEffect.state) assertThat(effectProgress).isNull() assertThat(snapEffect).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(snapEffect!!)).isTrue() assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) assertThat(action).isEqualTo(expectedAction) } /** * 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 TestScope.assertEffectReverses(pausedProgress: Float) { val reverseHaptics = LongPressHapticBuilder.createReversedEffect( pausedProgress, lowTickDuration, effectDuration, ) val state by collectLastValue(longPressEffect.state) assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(reverseHaptics).isNotNull() assertThat(vibratorHelper.hasVibratedWithEffects(reverseHaptics!!)).isTrue() } /** * 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] * 2. An action to reverse the animator is emitted */ private fun TestScope.assertEffectResets() { val effectProgress by collectLastValue(longPressEffect.effectProgress) val state by collectLastValue(longPressEffect.state) private fun TestScope.assertEffectReverses() { val action by collectLastValue(longPressEffect.actionType) assertThat(effectProgress).isNull() assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(action).isEqualTo(QSLongPressEffect.ActionType.REVERSE_ANIMATOR) } }
packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt +40 −71 Original line number Diff line number Diff line Loading @@ -16,20 +16,14 @@ package com.android.systemui.haptics.qs import android.animation.ValueAnimator import android.os.VibrationEffect import android.view.View 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.keyguard.domain.interactor.KeyguardInteractor import com.android.systemui.statusbar.VibratorHelper import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine /** Loading @@ -50,17 +44,14 @@ constructor( keyguardInteractor: KeyguardInteractor, ) { private var effectDuration = 0 var effectDuration = 0 private set /** Current state */ private var _state = MutableStateFlow(State.IDLE) val state = _state.asStateFlow() var state = State.IDLE private set /** Flows for view control and action */ private val _effectProgress = MutableStateFlow<Float?>(null) val effectProgress = _effectProgress.asStateFlow() // Actions to perform /** Flow for view control and action */ private val _postedActionType = MutableStateFlow<ActionType?>(null) val actionType: Flow<ActionType?> = combine( Loading @@ -85,19 +76,15 @@ constructor( private val snapEffect = LongPressHapticBuilder.createSnapEffect() private var effectAnimator: ValueAnimator? = null val hasInitialized: Boolean get() = longPressHint != null && effectAnimator != null get() = longPressHint != null @VisibleForTesting fun setState(state: State) { _state.value = state fun setState(newState: State) { state = newState } private fun reverse() { effectAnimator?.let { val pausedProgress = it.animatedFraction fun playReverseHaptics(pausedProgress: Float) { val effect = LongPressHapticBuilder.createReversedEffect( pausedProgress, Loading @@ -106,8 +93,6 @@ constructor( ) vibratorHelper?.cancel() vibrate(effect) it.reverse() } } private fun vibrate(effect: VibrationEffect?) { Loading @@ -117,23 +102,23 @@ constructor( } fun handleActionDown() { when (_state.value) { when (state) { State.IDLE -> { setState(State.TIMEOUT_WAIT) } State.RUNNING_BACKWARDS -> effectAnimator?.cancel() State.RUNNING_BACKWARDS -> _postedActionType.value = ActionType.CANCEL_ANIMATOR else -> {} } } fun handleActionUp() { when (_state.value) { when (state) { State.TIMEOUT_WAIT -> { _postedActionType.value = ActionType.CLICK setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() _postedActionType.value = ActionType.REVERSE_ANIMATOR setState(State.RUNNING_BACKWARDS) } else -> {} Loading @@ -141,44 +126,42 @@ constructor( } fun handleActionCancel() { when (_state.value) { when (state) { State.TIMEOUT_WAIT -> { setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() _postedActionType.value = ActionType.REVERSE_ANIMATOR setState(State.RUNNING_BACKWARDS) } else -> {} } } private fun handleAnimationStart() { fun handleAnimationStart() { vibrate(longPressHint) setState(State.RUNNING_FORWARD) } /** This function is called both when an animator completes or gets cancelled */ private fun handleAnimationComplete() { if (_state.value == State.RUNNING_FORWARD) { fun handleAnimationComplete() { if (state == State.RUNNING_FORWARD) { vibrate(snapEffect) _postedActionType.value = ActionType.LONG_PRESS _effectProgress.value = null } if (_state.value != State.TIMEOUT_WAIT) { if (state != State.TIMEOUT_WAIT) { // This will happen if the animator did not finish by being cancelled setState(State.IDLE) } } private fun handleAnimationCancel() { _effectProgress.value = null fun handleAnimationCancel() { setState(State.TIMEOUT_WAIT) } fun handleTimeoutComplete() { if (_state.value == State.TIMEOUT_WAIT && effectAnimator?.isRunning == false) { effectAnimator?.start() if (state == State.TIMEOUT_WAIT) { _postedActionType.value = ActionType.START_ANIMATOR } } Loading @@ -186,18 +169,6 @@ constructor( _postedActionType.value = null } /** Reset the effect by going back to a default [IDLE] state */ fun resetEffect() { if (effectAnimator?.isRunning == true) { effectAnimator?.cancel() } longPressHint = null effectAnimator = null _effectProgress.value = null _postedActionType.value = null setState(State.IDLE) } /** * Reset the effect with a new effect duration. * Loading @@ -205,27 +176,21 @@ constructor( * @return true if the effect initialized correctly */ fun initializeEffect(duration: Int): Boolean { // The effect can't reset if it is running // The effect can't initialize with a negative duration if (duration <= 0) return false resetEffect() effectDuration = duration effectAnimator = ValueAnimator.ofFloat(0f, 1f).apply { this.duration = effectDuration.toLong() interpolator = AccelerateDecelerateInterpolator() // There is no need to re-initialize if the duration has not changed if (duration == effectDuration) return true doOnStart { handleAnimationStart() } addUpdateListener { _effectProgress.value = animatedValue as Float } doOnEnd { handleAnimationComplete() } doOnCancel { handleAnimationCancel() } } effectDuration = duration longPressHint = LongPressHapticBuilder.createLongPressHint( durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION, durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION, effectDuration ) _postedActionType.value = ActionType.INITIALIZE_ANIMATOR setState(State.IDLE) return true } Loading @@ -241,5 +206,9 @@ constructor( CLICK, LONG_PRESS, RESET_AND_LONG_PRESS, START_ANIMATOR, REVERSE_ANIMATOR, CANCEL_ANIMATOR, INITIALIZE_ANIMATOR, } }
packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt +52 −16 File changed.Preview size limit exceeded, changes collapsed. Show changes