diff options
| author | 2024-04-11 15:15:57 +0000 | |
|---|---|---|
| committer | 2024-04-11 15:15:57 +0000 | |
| commit | 3b92b80f0298897a137897b4423f55a13465b52a (patch) | |
| tree | 790fa5e397be4b7cab6b6a888a05149738cf698e | |
| parent | e3ff4ab96facc4a8d0a308aa83d8b194002d9fac (diff) | |
| parent | 83c620d9bf330edc4508fcb27e0276a73ef81bae (diff) | |
Merge "Refactoring the quick settings long-press effect" into main
13 files changed, 461 insertions, 273 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt index 8f03717b42f2..3889703e74c4 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt @@ -26,39 +26,34 @@ 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 +import com.android.systemui.keyguard.domain.interactor.keyguardInteractor +import com.android.systemui.kosmos.backgroundCoroutineContext 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 vibratorHelper = kosmos.vibratorHelper private val effectDuration = 400 private val lowTickDuration = 12 @@ -68,19 +63,71 @@ class QSLongPressEffectTest : SysuiTestCase() { @Before fun setup() { - whenever( - vibratorHelper.getPrimitiveDurations( - VibrationEffect.Composition.PRIMITIVE_LOW_TICK, - VibrationEffect.Composition.PRIMITIVE_SPIN, - ) - ) - .thenReturn(intArrayOf(lowTickDuration, spinDuration)) + vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_LOW_TICK] = + lowTickDuration + vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_SPIN] = spinDuration + + kosmos.fakeKeyguardRepository.setKeyguardDismissible(true) longPressEffect = QSLongPressEffect( vibratorHelper, - effectDuration, + kosmos.keyguardInteractor, + CoroutineScope(kosmos.backgroundCoroutineContext), ) + 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() + + // 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.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.hasInitialized).isTrue() } @Test @@ -90,7 +137,8 @@ class QSLongPressEffectTest : SysuiTestCase() { longPressEffect.onTouch(testView, downEvent) // THEN the effect moves to the TIMEOUT_WAIT state - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) + val state by collectLastValue(longPressEffect.state) + assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } @Test @@ -100,7 +148,8 @@ class QSLongPressEffectTest : SysuiTestCase() { longPressEffect.onTouch(testView, cancelEvent) // THEN the effect goes back to idle and does not start - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) + val state by collectLastValue(longPressEffect.state) + assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) assertEffectDidNotStart() } @@ -121,7 +170,7 @@ class QSLongPressEffectTest : SysuiTestCase() { @Test fun onWaitComplete_whileWaiting_beginsEffect() = testWhileWaiting { // GIVEN the pressed timeout is complete - advanceTimeBy(QSLongPressEffect.PRESSED_TIMEOUT + 10L) + longPressEffect.handleTimeoutComplete() // THEN the effect starts assertEffectStarted() @@ -154,15 +203,28 @@ class QSLongPressEffectTest : SysuiTestCase() { } @Test - fun onAnimationComplete_effectEnds() = testWhileRunning { + fun onAnimationComplete_keyguardDismissible_effectEndsWithLongPress() = testWhileRunning { // GIVEN that the animation completes animatorTestRule.advanceTimeBy(effectDuration + 10L) - // THEN the long-press effect completes - assertEffectCompleted() + // THEN the long-press effect completes with a LONG_PRESS + assertEffectCompleted(QSLongPressEffect.ActionType.LONG_PRESS) } @Test + fun onAnimationComplete_keyguardNotDismissible_effectEndsWithResetAndLongPress() = + testWhileRunning { + // GIVEN that the keyguard is not dismissible + kosmos.fakeKeyguardRepository.setKeyguardDismissible(false) + + // GIVEN that the animation completes + animatorTestRule.advanceTimeBy(effectDuration + 10L) + + // 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) @@ -192,33 +254,21 @@ class QSLongPressEffectTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(effectDuration.toLong()) // THEN the state goes to [QSLongPressEffect.State.IDLE] - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) + val state by collectLastValue(longPressEffect.state) + assertThat(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() - } - } + with(kosmos) { testScope.runTest { 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) + longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) // THEN run the test test() @@ -228,16 +278,9 @@ class QSLongPressEffectTest : SysuiTestCase() { 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) + // GIVEN that the effect starts after the tap timeout is complete + longPressEffect.setState(QSLongPressEffect.State.TIMEOUT_WAIT) + longPressEffect.handleTimeoutComplete() // THEN run the test test() @@ -252,6 +295,7 @@ class QSLongPressEffectTest : SysuiTestCase() { */ private fun TestScope.assertEffectStarted() { val effectProgress by collectLastValue(longPressEffect.effectProgress) + val state by collectLastValue(longPressEffect.state) val longPressHint = LongPressHapticBuilder.createLongPressHint( lowTickDuration, @@ -259,10 +303,10 @@ class QSLongPressEffectTest : SysuiTestCase() { effectDuration, ) - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) + assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) assertThat(effectProgress).isEqualTo(0f) assertThat(longPressHint).isNotNull() - verify(vibratorHelper).vibrate(longPressHint!!) + assertThat(vibratorHelper.hasVibratedWithEffects(longPressHint!!)).isTrue() } /** @@ -274,11 +318,12 @@ class QSLongPressEffectTest : SysuiTestCase() { */ private fun TestScope.assertEffectDidNotStart() { val effectProgress by collectLastValue(longPressEffect.effectProgress) + val state by collectLastValue(longPressEffect.state) - assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) - assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) + assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) + assertThat(state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(effectProgress).isNull() - verify(vibratorHelper, never()).vibrate(any(/* type= */ VibrationEffect::class.java)) + assertThat(vibratorHelper.totalVibrations).isEqualTo(0) } /** @@ -286,18 +331,19 @@ class QSLongPressEffectTest : SysuiTestCase() { * 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 + * 4. The action to perform on the tile is the action given as a parameter */ - private fun TestScope.assertEffectCompleted() { + 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() - verify(vibratorHelper).vibrate(snapEffect!!) - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) - assertThat(action).isEqualTo(QSLongPressEffect.ActionType.LONG_PRESS) + assertThat(vibratorHelper.hasVibratedWithEffects(snapEffect!!)).isTrue() + assertThat(state).isEqualTo(QSLongPressEffect.State.IDLE) + assertThat(action).isEqualTo(expectedAction) } /** @@ -305,17 +351,18 @@ class QSLongPressEffectTest : SysuiTestCase() { * 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) { + private fun TestScope.assertEffectReverses(pausedProgress: Float) { val reverseHaptics = LongPressHapticBuilder.createReversedEffect( pausedProgress, lowTickDuration, effectDuration, ) + val state by collectLastValue(longPressEffect.state) - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) + assertThat(state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) assertThat(reverseHaptics).isNotNull() - verify(vibratorHelper).vibrate(reverseHaptics!!) + assertThat(vibratorHelper.hasVibratedWithEffects(reverseHaptics!!)).isTrue() } /** @@ -325,8 +372,9 @@ class QSLongPressEffectTest : SysuiTestCase() { */ private fun TestScope.assertEffectResets() { val effectProgress by collectLastValue(longPressEffect.effectProgress) - assertThat(effectProgress).isEqualTo(0f) + val state by collectLastValue(longPressEffect.state) - assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) + assertThat(effectProgress).isNull() + assertThat(state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) } } diff --git a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt index f1620d96b159..4327d18da97e 100644 --- a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt +++ b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt @@ -27,40 +27,65 @@ import androidx.annotation.VisibleForTesting import androidx.core.animation.doOnCancel import androidx.core.animation.doOnEnd import androidx.core.animation.doOnStart +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor import com.android.systemui.statusbar.VibratorHelper -import kotlinx.coroutines.CancellationException +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * 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 + * gestures of 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. */ -class QSLongPressEffect( +// TODO(b/332902869): In addition from being injectable, we can consider making it a singleton +class QSLongPressEffect +@Inject +constructor( private val vibratorHelper: VibratorHelper?, - private val effectDuration: Int, + val keyguardInteractor: KeyguardInteractor, + @Background bgScope: CoroutineScope, ) : View.OnTouchListener { + private var effectDuration = 0 + /** Current state */ - var state = State.IDLE - @VisibleForTesting set + private var _state = MutableStateFlow(State.IDLE) + val state = _state.stateIn(bgScope, SharingStarted.Lazily, State.IDLE) /** Flows for view control and action */ private val _effectProgress = MutableStateFlow<Float?>(null) - val effectProgress = _effectProgress.asStateFlow() + val effectProgress = _effectProgress.stateIn(bgScope, SharingStarted.Lazily, null) + + // Actions to perform + private val _postedActionType = MutableStateFlow<ActionType?>(null) + val actionType: StateFlow<ActionType?> = + combine( + _postedActionType, + keyguardInteractor.isKeyguardDismissible, + ) { action, isDismissible -> + if (!isDismissible && action == ActionType.LONG_PRESS) { + ActionType.RESET_AND_LONG_PRESS + } else { + action + } + } + .stateIn(bgScope, SharingStarted.Lazily, null) - private val _actionType = MutableStateFlow<ActionType?>(null) - val actionType = _actionType.asStateFlow() + // Should a tap timeout countdown begin + val shouldWaitForTapTimeout: Flow<Boolean> = state.map { it == State.TIMEOUT_WAIT } /** Haptic effects */ private val durations = @@ -69,41 +94,33 @@ class QSLongPressEffect( VibrationEffect.Composition.PRIMITIVE_SPIN ) - private val longPressHint = - LongPressHapticBuilder.createLongPressHint( - durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION, - durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION, - effectDuration - ) + private var longPressHint: VibrationEffect? = null 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 var effectAnimator: ValueAnimator? = null - private val effectAnimator = - ValueAnimator.ofFloat(0f, 1f).apply { - duration = effectDuration.toLong() - interpolator = AccelerateDecelerateInterpolator() + val hasInitialized: Boolean + get() = longPressHint != null && effectAnimator != null - doOnStart { handleAnimationStart() } - addUpdateListener { _effectProgress.value = animatedValue as Float } - doOnEnd { handleAnimationComplete() } - doOnCancel { handleAnimationCancel() } - } + @VisibleForTesting + fun setState(state: State) { + _state.value = state + } private fun reverse() { - val pausedProgress = effectAnimator.animatedFraction - val effect = - LongPressHapticBuilder.createReversedEffect( - pausedProgress, - durations?.get(0) ?: 0, - effectDuration, - ) - vibratorHelper?.cancel() - vibrate(effect) - effectAnimator.reverse() + effectAnimator?.let { + val pausedProgress = it.animatedFraction + val effect = + LongPressHapticBuilder.createReversedEffect( + pausedProgress, + durations?.get(0) ?: 0, + effectDuration, + ) + vibratorHelper?.cancel() + vibrate(effect) + it.reverse() + } } private fun vibrate(effect: VibrationEffect?) { @@ -129,52 +146,37 @@ class QSLongPressEffect( } private fun handleActionDown() { - when (state) { + when (_state.value) { State.IDLE -> { - startPressedTimeoutWait() - state = State.TIMEOUT_WAIT + setState(State.TIMEOUT_WAIT) } - State.RUNNING_BACKWARDS -> effectAnimator.cancel() + 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) { + when (_state.value) { State.TIMEOUT_WAIT -> { - waitJob?.cancel() - _actionType.value = ActionType.CLICK - state = State.IDLE + _postedActionType.value = ActionType.CLICK + setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() - state = State.RUNNING_BACKWARDS + setState(State.RUNNING_BACKWARDS) } else -> {} } } private fun handleActionCancel() { - when (state) { + when (_state.value) { State.TIMEOUT_WAIT -> { - waitJob?.cancel() - state = State.IDLE + setState(State.IDLE) } State.RUNNING_FORWARD -> { reverse() - state = State.RUNNING_BACKWARDS + setState(State.RUNNING_BACKWARDS) } else -> {} } @@ -182,54 +184,78 @@ class QSLongPressEffect( private fun handleAnimationStart() { vibrate(longPressHint) - state = State.RUNNING_FORWARD + setState(State.RUNNING_FORWARD) } /** This function is called both when an animator completes or gets cancelled */ private fun handleAnimationComplete() { - if (state == State.RUNNING_FORWARD) { + if (_state.value == State.RUNNING_FORWARD) { vibrate(snapEffect) - _actionType.value = ActionType.LONG_PRESS + _postedActionType.value = ActionType.LONG_PRESS _effectProgress.value = null } - if (state != State.TIMEOUT_WAIT) { + if (_state.value != State.TIMEOUT_WAIT) { // This will happen if the animator did not finish by being cancelled - state = State.IDLE + setState(State.IDLE) } } private fun handleAnimationCancel() { - _effectProgress.value = 0f - startPressedTimeoutWait() - state = State.TIMEOUT_WAIT + _effectProgress.value = null + setState(State.TIMEOUT_WAIT) } - private fun handleTimeoutComplete() { - if (state == State.TIMEOUT_WAIT && !effectAnimator.isRunning) { - effectAnimator.start() + fun handleTimeoutComplete() { + if (_state.value == State.TIMEOUT_WAIT && effectAnimator?.isRunning == false) { + effectAnimator?.start() } } fun clearActionType() { - _actionType.value = null + _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. * - * The effect will go back to an [IDLE] state where it can begin its logic with a new duration. - * * @param[duration] New duration for the long-press effect + * @return true if the effect initialized correctly */ - fun resetWithDuration(duration: Int) { + fun initializeEffect(duration: Int): Boolean { // The effect can't reset if it is running - if (effectAnimator.isRunning) return + if (duration <= 0) return false + + resetEffect() + effectDuration = duration + effectAnimator = + ValueAnimator.ofFloat(0f, 1f).apply { + this.duration = effectDuration.toLong() + interpolator = AccelerateDecelerateInterpolator() - effectAnimator.duration = duration.toLong() - _effectProgress.value = 0f - _actionType.value = null - waitJob?.cancel() - state = State.IDLE + doOnStart { handleAnimationStart() } + addUpdateListener { _effectProgress.value = animatedValue as Float } + doOnEnd { handleAnimationComplete() } + doOnCancel { handleAnimationCancel() } + } + longPressHint = + LongPressHapticBuilder.createLongPressHint( + durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION, + durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION, + effectDuration + ) + return true } enum class State { @@ -243,6 +269,7 @@ class QSLongPressEffect( enum class ActionType { CLICK, LONG_PRESS, + RESET_AND_LONG_PRESS, } companion object { diff --git a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt index f4998a7b8789..ddb9f35c74d9 100644 --- a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt @@ -21,56 +21,68 @@ 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.CancellationException import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch -class QSLongPressEffectViewBinder { - - private var handle: DisposableHandle? = null - val isBound: Boolean - get() = handle != null - +// TODO(b/332903800) +object QSLongPressEffectViewBinder { fun bind( tile: QSTileViewImpl, + qsLongPressEffect: QSLongPressEffect?, tileSpec: String?, - effect: QSLongPressEffect?, - ) { - if (effect == null) return - - handle = - tile.repeatWhenAttached { - repeatOnLifecycle(Lifecycle.State.CREATED) { - effect.scope = this - val tag = "${tileSpec ?: "unknownTileSpec"}#LongPressEffect" + ): DisposableHandle? { + if (qsLongPressEffect == null) return null - launch("$tag#progress") { - effect.effectProgress.collect { progress -> - progress?.let { - if (it == 0f) { - tile.bringToFront() - } + return tile.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.CREATED) { + val tag = "${tileSpec ?: "unknownTileSpec"}#LongPressEffect" + // Progress of the effect + launch("$tag#progress") { + qsLongPressEffect.effectProgress.collect { progress -> + progress?.let { + if (it == 0f) { + tile.bringToFront() + } else { tile.updateLongPressEffectProperties(it) } } } + } - launch("$tag#action") { - effect.actionType.collect { action -> - action?.let { - when (it) { - QSLongPressEffect.ActionType.CLICK -> tile.performClick() - QSLongPressEffect.ActionType.LONG_PRESS -> - tile.performLongClick() + // Action to perform + launch("$tag#action") { + qsLongPressEffect.actionType.collect { action -> + action?.let { + when (it) { + QSLongPressEffect.ActionType.CLICK -> tile.performClick() + QSLongPressEffect.ActionType.LONG_PRESS -> tile.performLongClick() + QSLongPressEffect.ActionType.RESET_AND_LONG_PRESS -> { + tile.resetLongPressEffectProperties() + tile.performLongClick() } - effect.clearActionType() } + qsLongPressEffect.clearActionType() } } } - } - } - fun dispose() { - handle?.dispose() - handle = null + // Tap timeout wait + launch("$tag#timeout") { + qsLongPressEffect.shouldWaitForTapTimeout + .filter { it } + .collect { + try { + delay(QSLongPressEffect.PRESSED_TIMEOUT) + qsLongPressEffect.handleTimeoutComplete() + } catch (_: CancellationException) { + qsLongPressEffect.resetEffect() + } + } + } + } + } } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java index 55dc4859cf90..b8c3c1a2af5f 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java @@ -29,6 +29,7 @@ import androidx.annotation.Nullable; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; import com.android.systemui.dump.DumpManager; +import com.android.systemui.haptics.qs.QSLongPressEffect; import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager; import com.android.systemui.media.controls.ui.view.MediaHost; import com.android.systemui.media.controls.ui.view.MediaHostState; @@ -41,13 +42,13 @@ import com.android.systemui.settings.brightness.BrightnessController; import com.android.systemui.settings.brightness.BrightnessMirrorHandler; import com.android.systemui.settings.brightness.BrightnessSliderController; import com.android.systemui.settings.brightness.MirrorController; -import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager; import com.android.systemui.statusbar.policy.SplitShadeStateController; import com.android.systemui.tuner.TunerService; import javax.inject.Inject; import javax.inject.Named; +import javax.inject.Provider; /** * Controller for {@link QSPanel}. @@ -94,10 +95,10 @@ public class QSPanelController extends QSPanelControllerBase<QSPanel> { StatusBarKeyguardViewManager statusBarKeyguardViewManager, SplitShadeStateController splitShadeStateController, SceneContainerFlags sceneContainerFlags, - VibratorHelper vibratorHelper) { + Provider<QSLongPressEffect> longPRessEffectProvider) { super(view, qsHost, qsCustomizerController, usingMediaPlayer, mediaHost, metricsLogger, uiEventLogger, qsLogger, dumpManager, splitShadeStateController, - vibratorHelper); + longPRessEffectProvider); mTunerService = tunerService; mQsCustomizerController = qsCustomizerController; mQsTileRevealControllerFactory = qsTileRevealControllerFactory; diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java index d8e81875bbbf..583cfb9ab47e 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java @@ -17,6 +17,7 @@ package com.android.systemui.qs; import static com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import static com.android.systemui.Flags.quickSettingsVisualHapticsLongpress; import android.annotation.NonNull; import android.annotation.Nullable; @@ -32,6 +33,7 @@ import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; import com.android.systemui.Dumpable; import com.android.systemui.dump.DumpManager; +import com.android.systemui.haptics.qs.QSLongPressEffect; import com.android.systemui.media.controls.ui.view.MediaHost; import com.android.systemui.plugins.qs.QSTile; import com.android.systemui.plugins.qs.QSTileView; @@ -39,7 +41,6 @@ import com.android.systemui.qs.customize.QSCustomizerController; import com.android.systemui.qs.external.CustomTile; import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.qs.tileimpl.QSTileViewImpl; -import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.policy.SplitShadeStateController; import com.android.systemui.util.ViewController; import com.android.systemui.util.animation.DisappearParameters; @@ -55,6 +56,8 @@ import java.util.Objects; import java.util.function.Consumer; import java.util.stream.Collectors; +import javax.inject.Provider; + /** * Controller for QSPanel views. * @@ -88,7 +91,7 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr private SplitShadeStateController mSplitShadeStateController; - private final VibratorHelper mVibratorHelper; + private final Provider<QSLongPressEffect> mLongPressEffectProvider; @VisibleForTesting protected final QSPanel.OnConfigurationChangedListener mOnConfigurationChangedListener = @@ -148,7 +151,7 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr QSLogger qsLogger, DumpManager dumpManager, SplitShadeStateController splitShadeStateController, - VibratorHelper vibratorHelper + Provider<QSLongPressEffect> longPressEffectProvider ) { super(view); mHost = host; @@ -162,7 +165,7 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr mSplitShadeStateController = splitShadeStateController; mShouldUseSplitNotificationShade = mSplitShadeStateController.shouldUseSplitNotificationShade(getResources()); - mVibratorHelper = vibratorHelper; + mLongPressEffectProvider = longPressEffectProvider; } @Override @@ -305,8 +308,14 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr } private void addTile(final QSTile tile, boolean collapsedView) { + QSLongPressEffect longPressEffect; + if (quickSettingsVisualHapticsLongpress()) { + longPressEffect = mLongPressEffectProvider.get(); + } else { + longPressEffect = null; + } final QSTileViewImpl tileView = new QSTileViewImpl( - getContext(), collapsedView, mVibratorHelper); + getContext(), collapsedView, longPressEffect); final TileRecord r = new TileRecord(tile, tileView); // TODO(b/250618218): Remove the QSLogger in QSTileViewImpl once we know the root cause of // b/250618218. diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java index 05bb08813cc5..6cda740dd1a8 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java @@ -25,6 +25,7 @@ import androidx.annotation.VisibleForTesting; import com.android.internal.logging.MetricsLogger; import com.android.internal.logging.UiEventLogger; import com.android.systemui.dump.DumpManager; +import com.android.systemui.haptics.qs.QSLongPressEffect; import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager; import com.android.systemui.media.controls.ui.view.MediaHost; import com.android.systemui.plugins.qs.QSTile; @@ -32,7 +33,6 @@ import com.android.systemui.qs.customize.QSCustomizerController; import com.android.systemui.qs.dagger.QSScope; import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.res.R; -import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.policy.SplitShadeStateController; import com.android.systemui.util.leak.RotationUtils; @@ -58,10 +58,11 @@ public class QuickQSPanelController extends QSPanelControllerBase<QuickQSPanel> Provider<Boolean> usingCollapsedLandscapeMediaProvider, MetricsLogger metricsLogger, UiEventLogger uiEventLogger, QSLogger qsLogger, DumpManager dumpManager, SplitShadeStateController splitShadeStateController, - VibratorHelper vibratorHelper + Provider<QSLongPressEffect> longPressEffectProvider ) { super(view, qsHost, qsCustomizerController, usingMediaPlayer, mediaHost, metricsLogger, - uiEventLogger, qsLogger, dumpManager, splitShadeStateController, vibratorHelper); + uiEventLogger, qsLogger, dumpManager, splitShadeStateController, + longPressEffectProvider); mUsingCollapsedLandscapeMediaProvider = usingCollapsedLandscapeMediaProvider; } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt index 30044856a7d4..ca71870845e0 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt @@ -62,15 +62,15 @@ import com.android.systemui.plugins.qs.QSTileView import com.android.systemui.qs.logging.QSLogger import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH import com.android.systemui.res.R -import com.android.systemui.statusbar.VibratorHelper import com.android.systemui.util.children +import kotlinx.coroutines.DisposableHandle import java.util.Objects private const val TAG = "QSTileViewImpl" open class QSTileViewImpl @JvmOverloads constructor( context: Context, private val collapsed: Boolean = false, - private val vibratorHelper: VibratorHelper? = null, + private val longPressEffect: QSLongPressEffect? = null, ) : QSTileView(context), HeightOverrideable, LaunchableView { companion object { @@ -180,15 +180,13 @@ open class QSTileViewImpl @JvmOverloads constructor( private val locInScreen = IntArray(2) /** Visuo-haptic long-press effects */ - private var longPressEffect: QSLongPressEffect? = null - private val longPressEffectViewBinder = QSLongPressEffectViewBinder() private var initialLongPressProperties: QSLongPressProperties? = null private var finalLongPressProperties: QSLongPressProperties? = null private val colorEvaluator = ArgbEvaluator.getInstance() - val hasLongPressEffect: Boolean - get() = longPressEffect != null - @VisibleForTesting val isLongPressEffectBound: Boolean - get() = longPressEffectViewBinder.isBound + val isLongPressEffectInitialized: Boolean + get() = longPressEffect?.hasInitialized == true + @VisibleForTesting + var longPressEffectHandle: DisposableHandle? = null init { val typedValue = TypedValue() @@ -325,6 +323,13 @@ open class QSTileViewImpl @JvmOverloads constructor( } private fun updateHeight() { + // TODO(b/332900989): Find a more robust way of resetting the tile if not reset by the + // launch animation. + if (scaleX != 1f || scaleY != 1f) { + // The launch animation of a long-press effect did not reset the long-press effect so + // we must do it here + resetLongPressEffectProperties() + } val actualHeight = if (heightOverride != HeightOverrideable.NO_OVERRIDE) { heightOverride } else { @@ -614,25 +619,26 @@ open class QSTileViewImpl @JvmOverloads constructor( lastIconTint = icon.getColor(state) // Long-press effects - if (quickSettingsVisualHapticsLongpress()){ - if (state.handlesLongClick && maybeCreateAndInitializeLongPressEffect()) { - // set the valid long-press effect as the touch listener - showRippleEffect = false + if (state.handlesLongClick && + longPressEffect?.initializeEffect(longPressEffectDuration) == true) { + // set the valid long-press effect as the touch listener + if (longPressEffectHandle == null) { + longPressEffectHandle = + QSLongPressEffectViewBinder.bind(this, longPressEffect, state.spec) setOnTouchListener(longPressEffect) - if (!longPressEffectViewBinder.isBound) { - longPressEffectViewBinder.bind(this, state.spec, longPressEffect) - } - } else { - // Long-press effects might have been enabled before but the new state does not - // handle a long-press. In this case, we go back to the behaviour of a regular tile - // and clean-up the resources - longPressEffectViewBinder.dispose() - showRippleEffect = isClickable - setOnTouchListener(null) - longPressEffect = null - initialLongPressProperties = null - finalLongPressProperties = null } + showRippleEffect = false + initializeLongPressProperties() + } else { + // Long-press effects might have been enabled before but the new state does not + // handle a long-press. In this case, we go back to the behaviour of a regular tile + // and clean-up the resources + setOnTouchListener(null) + longPressEffectHandle?.dispose() + longPressEffectHandle = null + showRippleEffect = isClickable + initialLongPressProperties = null + finalLongPressProperties = null } } @@ -824,7 +830,7 @@ open class QSTileViewImpl @JvmOverloads constructor( private fun interpolateFloat(fraction: Float, start: Float, end: Float): Float = start + fraction * (end - start) - private fun resetLongPressEffectProperties() { + fun resetLongPressEffectProperties() { scaleY = 1f scaleX = 1f for (child in children) { @@ -842,27 +848,6 @@ open class QSTileViewImpl @JvmOverloads constructor( icon.setTint(icon.mIcon as ImageView, lastIconTint) } - private fun maybeCreateAndInitializeLongPressEffect(): Boolean { - // Don't setup the effect if the long-press duration is invalid - val effectDuration = longPressEffectDuration - if (effectDuration <= 0) { - longPressEffect = null - return false - } - - initializeLongPressProperties() - if (longPressEffect == null) { - longPressEffect = - QSLongPressEffect( - vibratorHelper, - effectDuration, - ) - } else { - longPressEffect?.resetWithDuration(effectDuration) - } - return true - } - private fun initializeLongPressProperties() { initialLongPressProperties = QSLongPressProperties( diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java index 0101741a9242..542bfaaa8484 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java @@ -16,6 +16,8 @@ package com.android.systemui.qs; +import static com.android.systemui.Flags.FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS; + import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; @@ -33,6 +35,8 @@ import static org.mockito.Mockito.when; import android.content.res.Configuration; import android.content.res.Resources; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; import android.testing.AndroidTestingRunner; import android.testing.TestableLooper.RunWithLooper; import android.view.ContextThemeWrapper; @@ -45,13 +49,14 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.internal.logging.testing.UiEventLoggerFake; import com.android.systemui.SysuiTestCase; import com.android.systemui.dump.DumpManager; +import com.android.systemui.haptics.qs.QSLongPressEffect; +import com.android.systemui.kosmos.KosmosJavaAdapter; import com.android.systemui.media.controls.ui.view.MediaHost; import com.android.systemui.plugins.qs.QSTile; import com.android.systemui.qs.customize.QSCustomizerController; import com.android.systemui.qs.logging.QSLogger; import com.android.systemui.qs.tileimpl.QSTileImpl; import com.android.systemui.res.R; -import com.android.systemui.statusbar.VibratorHelper; import com.android.systemui.statusbar.policy.ResourcesSplitShadeStateController; import com.android.systemui.util.animation.DisappearParameters; @@ -66,11 +71,14 @@ import java.io.StringWriter; import java.util.Collections; import java.util.List; +import javax.inject.Provider; + @RunWith(AndroidTestingRunner.class) @RunWithLooper @SmallTest public class QSPanelControllerBaseTest extends SysuiTestCase { + private final KosmosJavaAdapter mKosmos = new KosmosJavaAdapter(this); @Mock private QSPanel mQSPanel; @Mock @@ -101,8 +109,8 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { Configuration mConfiguration; @Mock Runnable mHorizontalLayoutListener; - @Mock - VibratorHelper mVibratorHelper; + private TestableLongPressEffectProvider mLongPressEffectProvider = + new TestableLongPressEffectProvider(); private QSPanelControllerBase<QSPanel> mController; @@ -114,7 +122,7 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { DumpManager dumpManager) { super(view, host, qsCustomizerController, true, mediaHost, metricsLogger, uiEventLogger, qsLogger, dumpManager, new ResourcesSplitShadeStateController(), - mVibratorHelper); + mLongPressEffectProvider); } @Override @@ -123,6 +131,17 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { } } + private class TestableLongPressEffectProvider implements Provider<QSLongPressEffect> { + + private int mEffectsProvided = 0; + + @Override + public QSLongPressEffect get() { + mEffectsProvided++; + return mKosmos.getQsLongPressEffect(); + } + } + @Before public void setup() throws Exception { MockitoAnnotations.initMocks(this); @@ -421,6 +440,27 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { } @Test + @EnableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) + public void setTiles_longPressEffectEnabled_nonNullLongPressEffectsAreProvided() { + mLongPressEffectProvider.mEffectsProvided = 0; + when(mQSHost.getTiles()).thenReturn(List.of(mQSTile, mOtherTile)); + mController.setTiles(); + + // There is one non-null effect provided for each tile in the host + assertThat(mLongPressEffectProvider.mEffectsProvided).isEqualTo(2); + } + + @Test + @DisableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) + public void setTiles_longPressEffectDisabled_noLongPressEffectsAreProvided() { + mLongPressEffectProvider.mEffectsProvided = 0; + when(mQSHost.getTiles()).thenReturn(List.of(mQSTile, mOtherTile)); + mController.setTiles(); + + assertThat(mLongPressEffectProvider.mEffectsProvided).isEqualTo(0); + } + + @Test public void setTiles_differentTiles_extraTileRemoved() { when(mQSHost.getTiles()).thenReturn(List.of(mQSTile, mOtherTile)); mController.setTiles(); diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt index 916e8ddb6e8a..a60494f87fb4 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt @@ -7,19 +7,19 @@ import android.testing.TestableResources import android.view.ContextThemeWrapper import com.android.internal.logging.MetricsLogger import com.android.internal.logging.UiEventLogger -import com.android.systemui.res.R import com.android.systemui.SysuiTestCase import com.android.systemui.dump.DumpManager +import com.android.systemui.haptics.qs.QSLongPressEffect import com.android.systemui.media.controls.ui.view.MediaHost import com.android.systemui.media.controls.ui.view.MediaHostState import com.android.systemui.plugins.FalsingManager import com.android.systemui.plugins.qs.QSTile import com.android.systemui.qs.customize.QSCustomizerController import com.android.systemui.qs.logging.QSLogger +import com.android.systemui.res.R import com.android.systemui.scene.shared.flag.FakeSceneContainerFlags import com.android.systemui.settings.brightness.BrightnessController import com.android.systemui.settings.brightness.BrightnessSliderController -import com.android.systemui.statusbar.VibratorHelper import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager import com.android.systemui.statusbar.policy.ResourcesSplitShadeStateController import com.android.systemui.tuner.TunerService @@ -36,6 +36,7 @@ import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations +import javax.inject.Provider import org.mockito.Mockito.`when` as whenever @SmallTest @@ -62,7 +63,7 @@ class QSPanelControllerTest : SysuiTestCase() { @Mock private lateinit var statusBarKeyguardViewManager: StatusBarKeyguardViewManager @Mock private lateinit var configuration: Configuration @Mock private lateinit var pagedTileLayout: PagedTileLayout - @Mock private lateinit var vibratorHelper: VibratorHelper + @Mock private lateinit var longPressEffectProvider: Provider<QSLongPressEffect> private val sceneContainerFlags = FakeSceneContainerFlags() @@ -103,7 +104,7 @@ class QSPanelControllerTest : SysuiTestCase() { statusBarKeyguardViewManager, ResourcesSplitShadeStateController(), sceneContainerFlags, - vibratorHelper, + longPressEffectProvider, ) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt index 71a9a8b3318f..1eb0a51bcaf6 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt @@ -22,15 +22,15 @@ import android.view.ContextThemeWrapper import androidx.test.filters.SmallTest import com.android.internal.logging.MetricsLogger import com.android.internal.logging.testing.UiEventLoggerFake -import com.android.systemui.res.R import com.android.systemui.SysuiTestCase import com.android.systemui.dump.DumpManager +import com.android.systemui.haptics.qs.QSLongPressEffect import com.android.systemui.media.controls.ui.view.MediaHost import com.android.systemui.media.controls.ui.view.MediaHostState import com.android.systemui.plugins.qs.QSTile import com.android.systemui.qs.customize.QSCustomizerController import com.android.systemui.qs.logging.QSLogger -import com.android.systemui.statusbar.VibratorHelper +import com.android.systemui.res.R import com.android.systemui.statusbar.policy.ResourcesSplitShadeStateController import com.android.systemui.util.leak.RotationUtils import org.junit.After @@ -45,6 +45,7 @@ import org.mockito.Mockito.reset import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations +import javax.inject.Provider import org.mockito.Mockito.`when` as whenever @SmallTest @@ -60,7 +61,7 @@ class QuickQSPanelControllerTest : SysuiTestCase() { @Mock private lateinit var tile: QSTile @Mock private lateinit var tileLayout: TileLayout @Captor private lateinit var captor: ArgumentCaptor<QSPanel.OnConfigurationChangedListener> - @Mock private lateinit var vibratorHelper: VibratorHelper + @Mock private lateinit var longPressEffectProvider: Provider<QSLongPressEffect> private val uiEventLogger = UiEventLoggerFake() private val dumpManager = DumpManager() @@ -92,7 +93,7 @@ class QuickQSPanelControllerTest : SysuiTestCase() { uiEventLogger, qsLogger, dumpManager, - vibratorHelper, + longPressEffectProvider, ) controller.init() @@ -161,7 +162,7 @@ class QuickQSPanelControllerTest : SysuiTestCase() { uiEventLogger: UiEventLoggerFake, qsLogger: QSLogger, dumpManager: DumpManager, - vibratorHelper: VibratorHelper, + longPressEffectProvider: Provider<QSLongPressEffect>, ) : QuickQSPanelController( view, @@ -175,7 +176,7 @@ class QuickQSPanelControllerTest : SysuiTestCase() { qsLogger, dumpManager, ResourcesSplitShadeStateController(), - vibratorHelper, + longPressEffectProvider, ) { private var rotation = RotationUtils.ROTATION_NONE diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tileimpl/QSTileViewImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tileimpl/QSTileViewImplTest.kt index 2b1ac915f430..512ca5315530 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/tileimpl/QSTileViewImplTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tileimpl/QSTileViewImplTest.kt @@ -18,7 +18,6 @@ package com.android.systemui.qs.tileimpl import android.content.Context import android.graphics.drawable.Drawable -import android.platform.test.annotations.EnableFlags import android.service.quicksettings.Tile import android.testing.AndroidTestingRunner import android.testing.TestableLooper @@ -28,10 +27,12 @@ import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.TextView import androidx.test.filters.SmallTest -import com.android.systemui.Flags.FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS import com.android.systemui.res.R import com.android.systemui.SysuiTestCase +import com.android.systemui.haptics.qs.QSLongPressEffect +import com.android.systemui.haptics.qs.qsLongPressEffect import com.android.systemui.plugins.qs.QSTile +import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test @@ -50,13 +51,14 @@ class QSTileViewImplTest : SysuiTestCase() { private lateinit var tileView: FakeTileView private lateinit var customDrawableView: View private lateinit var chevronView: View + private val kosmos = testKosmos() @Before fun setUp() { MockitoAnnotations.initMocks(this) context.ensureTestableResources() - tileView = FakeTileView(context, false) + tileView = FakeTileView(context, false, kosmos.qsLongPressEffect) customDrawableView = tileView.requireViewById(R.id.customDrawable) chevronView = tileView.requireViewById(R.id.chevron) } @@ -383,7 +385,6 @@ class QSTileViewImplTest : SysuiTestCase() { } @Test - @EnableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) fun onStateChange_longPressEffectActive_withInvalidDuration_doesNotCreateEffect() { val state = QSTile.State() // A state that handles longPress @@ -393,12 +394,11 @@ class QSTileViewImplTest : SysuiTestCase() { // WHEN the state changes tileView.changeState(state) - // THEN the long-press effect is not created - assertThat(tileView.hasLongPressEffect).isFalse() + // THEN the long-press effect is not initialized + assertThat(tileView.isLongPressEffectInitialized).isFalse() } @Test - @EnableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) fun onStateChange_longPressEffectActive_withValidDuration_createsEffect() { // GIVEN a test state that handles long-press and a valid long-press effect duration val state = QSTile.State() @@ -406,12 +406,11 @@ class QSTileViewImplTest : SysuiTestCase() { // WHEN the state changes tileView.changeState(state) - // THEN the long-press effect created - assertThat(tileView.hasLongPressEffect).isTrue() + // THEN the long-press effect is initialized + assertThat(tileView.isLongPressEffectInitialized).isTrue() } @Test - @EnableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) fun onStateChange_fromLongPress_to_noLongPress_unBoundsTile() { // GIVEN a state that no longer handles long-press val state = QSTile.State() @@ -421,11 +420,10 @@ class QSTileViewImplTest : SysuiTestCase() { tileView.changeState(state) // THEN the view binder no longer binds the view to the long-press effect - assertThat(tileView.isLongPressEffectBound).isFalse() + assertThat(tileView.longPressEffectHandle).isNull() } @Test - @EnableFlags(FLAG_QUICK_SETTINGS_VISUAL_HAPTICS_LONGPRESS) fun onStateChange_fromNoLongPress_to_longPress_bindsTile() { // GIVEN that the tile has changed to a state that does not handle long-press val state = QSTile.State() @@ -437,15 +435,53 @@ class QSTileViewImplTest : SysuiTestCase() { tileView.changeState(state) // THEN the view is bounded to the long-press effect - assertThat(tileView.isLongPressEffectBound).isTrue() + assertThat(tileView.longPressEffectHandle).isNotNull() + } + + @Test + fun onStateChange_withoutLongPressEffect_fromLongPress_to_noLongPress_neverBindsEffect() { + // GIVEN a tile where the long-press effect is null + tileView = FakeTileView(context, false, null) + + // GIVEN a state that no longer handles long-press + val state = QSTile.State() + state.handlesLongClick = false + + // WHEN the state changes + tileView.changeState(state) + + // THEN the view binder does not bind the view and no effect is initialized + assertThat(tileView.longPressEffectHandle).isNull() + assertThat(tileView.isLongPressEffectInitialized).isFalse() + } + + @Test + fun onStateChange_withoutLongPressEffect_fromNoLongPress_to_longPress_neverBindsEffect() { + // GIVEN a tile where the long-press effect is null + tileView = FakeTileView(context, false, null) + + // GIVEN that the tile has changed to a state that does not handle long-press + val state = QSTile.State() + state.handlesLongClick = false + tileView.changeState(state) + + // WHEN the state changes back to handling long-press + state.handlesLongClick = true + tileView.changeState(state) + + // THEN the view binder does not bind the view and no effect is initialized + assertThat(tileView.longPressEffectHandle).isNull() + assertThat(tileView.isLongPressEffectInitialized).isFalse() } class FakeTileView( context: Context, - collapsed: Boolean + collapsed: Boolean, + longPressEffect: QSLongPressEffect?, ) : QSTileViewImpl( ContextThemeWrapper(context, R.style.Theme_SystemUI_QuickSettings), - collapsed + collapsed, + longPressEffect, ) { var constantLongPressEffectDuration = 500 diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/qs/QSLongPressEffectKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/qs/QSLongPressEffectKosmos.kt new file mode 100644 index 000000000000..636d509663a2 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/qs/QSLongPressEffectKosmos.kt @@ -0,0 +1,25 @@ +/* + * 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 com.android.systemui.haptics.vibratorHelper +import com.android.systemui.keyguard.domain.interactor.keyguardInteractor +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.kosmos.testScope + +val Kosmos.qsLongPressEffect by + Kosmos.Fixture { QSLongPressEffect(vibratorHelper, keyguardInteractor, testScope) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt index a46d35842cf3..fdc3e0a22627 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/kosmos/KosmosJavaAdapter.kt @@ -33,6 +33,7 @@ import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor import com.android.systemui.flags.fakeFeatureFlagsClassic import com.android.systemui.globalactions.domain.interactor.globalActionsInteractor +import com.android.systemui.haptics.qs.qsLongPressEffect import com.android.systemui.jank.interactionJankMonitor import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository @@ -110,6 +111,7 @@ class KosmosJavaAdapter( kosmos.sharedNotificationContainerInteractor } val brightnessMirrorShowingInteractor by lazy { kosmos.brightnessMirrorShowingInteractor } + val qsLongPressEffect by lazy { kosmos.qsLongPressEffect } init { kosmos.applicationContext = testCase.context |