diff options
| author | 2024-03-12 14:09:31 +0000 | |
|---|---|---|
| committer | 2024-03-12 14:09:31 +0000 | |
| commit | 5285cdb846a19b935de3973e51ba963ea0d6c2f6 (patch) | |
| tree | 08575bf45b8e0574e1856e5b90c4fe1b9c103b28 | |
| parent | 2abfd31296e60bec2f510e7e53f6a7700952e545 (diff) | |
| parent | f6b248b09ea4ac50475c0aa9efbf28206f7c58f0 (diff) | |
Merge changes from topic "QS_VISUO_HAPTICS" into main
* changes:
Introducing visuo-haptic effects for long-press on QS tiles.
Introducing the QSLongPressEffect for visuo-haptic effects on QS tiles.
14 files changed, 991 insertions, 11 deletions
diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt index 5d5f12e8e567..3f57f88a13d3 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt @@ -337,6 +337,7 @@ constructor( if (ghostedView is LaunchableView) { // Restore the ghosted view visibility. ghostedView.setShouldBlockVisibilityChanges(false) + ghostedView.onActivityLaunchAnimationEnd() } else { // Make the ghosted view visible. We ensure that the view is considered VISIBLE by // accessibility by first making it INVISIBLE then VISIBLE (see b/204944038#comment17 diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt index ed8e70568b48..da6ccaa2dd2c 100644 --- a/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt @@ -38,6 +38,9 @@ interface LaunchableView { * @param block whether we should block/postpone all calls to `setVisibility`. */ fun setShouldBlockVisibilityChanges(block: Boolean) + + /** Perform an action when the activity launch animation ends */ + fun onActivityLaunchAnimationEnd() {} } /** A delegate that can be used by views to make the implementation of [LaunchableView] easier. */ 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 new file mode 100644 index 000000000000..8f03717b42f2 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.qs + +import android.os.VibrationEffect +import android.testing.TestableLooper.RunWithLooper +import android.view.MotionEvent +import android.view.View +import androidx.test.core.view.MotionEventBuilder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.animation.AnimatorTestRule +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.kosmos.testScope +import com.android.systemui.statusbar.VibratorHelper +import com.android.systemui.testKosmos +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +@RunWithLooper(setAsMainLooper = true) +class QSLongPressEffectTest : SysuiTestCase() { + + @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule() + @Mock private lateinit var vibratorHelper: VibratorHelper + @Mock private lateinit var testView: View + @get:Rule val animatorTestRule = AnimatorTestRule(this) + private val kosmos = testKosmos() + + private val effectDuration = 400 + private val lowTickDuration = 12 + private val spinDuration = 133 + + private lateinit var longPressEffect: QSLongPressEffect + + @Before + fun setup() { + whenever( + vibratorHelper.getPrimitiveDurations( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + VibrationEffect.Composition.PRIMITIVE_SPIN, + ) + ) + .thenReturn(intArrayOf(lowTickDuration, spinDuration)) + + longPressEffect = + QSLongPressEffect( + vibratorHelper, + effectDuration, + ) + } + + @Test + fun onActionDown_whileIdle_startsWait() = testWithScope { + // GIVEN an action down event occurs + val downEvent = buildMotionEvent(MotionEvent.ACTION_DOWN) + longPressEffect.onTouch(testView, downEvent) + + // THEN the effect moves to the TIMEOUT_WAIT state + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) + } + + @Test + fun onActionCancel_whileWaiting_goesIdle() = testWhileWaiting { + // GIVEN an action cancel occurs + val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL) + longPressEffect.onTouch(testView, cancelEvent) + + // THEN the effect goes back to idle and does not start + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) + assertEffectDidNotStart() + } + + @Test + fun onActionUp_whileWaiting_performsClick() = testWhileWaiting { + // GIVEN an action is being collected + val action by collectLastValue(longPressEffect.actionType) + + // GIVEN an action up occurs + val upEvent = buildMotionEvent(MotionEvent.ACTION_UP) + longPressEffect.onTouch(testView, upEvent) + + // THEN the action to invoke is the click action and the effect does not start + assertThat(action).isEqualTo(QSLongPressEffect.ActionType.CLICK) + assertEffectDidNotStart() + } + + @Test + fun onWaitComplete_whileWaiting_beginsEffect() = testWhileWaiting { + // GIVEN the pressed timeout is complete + advanceTimeBy(QSLongPressEffect.PRESSED_TIMEOUT + 10L) + + // THEN the effect starts + assertEffectStarted() + } + + @Test + fun onActionUp_whileEffectHasBegun_reversesEffect() = testWhileRunning { + // GIVEN that the effect is at the middle of its completion (progress of 50%) + animatorTestRule.advanceTimeBy(effectDuration / 2L) + + // WHEN an action up occurs + val upEvent = buildMotionEvent(MotionEvent.ACTION_UP) + longPressEffect.onTouch(testView, upEvent) + + // THEN the effect gets reversed at 50% progress + assertEffectReverses(0.5f) + } + + @Test + fun onActionCancel_whileEffectHasBegun_reversesEffect() = testWhileRunning { + // GIVEN that the effect is at the middle of its completion (progress of 50%) + animatorTestRule.advanceTimeBy(effectDuration / 2L) + + // WHEN an action cancel occurs + val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL) + longPressEffect.onTouch(testView, cancelEvent) + + // THEN the effect gets reversed at 50% progress + assertEffectReverses(0.5f) + } + + @Test + fun onAnimationComplete_effectEnds() = testWhileRunning { + // GIVEN that the animation completes + animatorTestRule.advanceTimeBy(effectDuration + 10L) + + // THEN the long-press effect completes + assertEffectCompleted() + } + + @Test + fun onActionDown_whileRunningBackwards_resets() = testWhileRunning { + // GIVEN that the effect is at the middle of its completion (progress of 50%) + animatorTestRule.advanceTimeBy(effectDuration / 2L) + + // GIVEN an action cancel occurs and the effect gets reversed + val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL) + longPressEffect.onTouch(testView, cancelEvent) + + // GIVEN an action down occurs + val downEvent = buildMotionEvent(MotionEvent.ACTION_DOWN) + longPressEffect.onTouch(testView, downEvent) + + // THEN the effect resets + assertEffectResets() + } + + @Test + fun onAnimationComplete_whileRunningBackwards_goesToIdle() = testWhileRunning { + // GIVEN that the effect is at the middle of its completion (progress of 50%) + animatorTestRule.advanceTimeBy(effectDuration / 2L) + + // GIVEN an action cancel occurs and the effect gets reversed + val cancelEvent = buildMotionEvent(MotionEvent.ACTION_CANCEL) + longPressEffect.onTouch(testView, cancelEvent) + + // GIVEN that the animation completes after a sufficient amount of time + animatorTestRule.advanceTimeBy(effectDuration.toLong()) + + // THEN the state goes to [QSLongPressEffect.State.IDLE] + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) + } + + private fun buildMotionEvent(action: Int): MotionEvent = + MotionEventBuilder.newBuilder().setAction(action).build() + + private fun testWithScope(test: suspend TestScope.() -> Unit) = + with(kosmos) { + testScope.runTest { + // GIVEN an effect with a testing scope + longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + // THEN run the test + test() + } + } + + private fun testWhileWaiting(test: suspend TestScope.() -> Unit) = + with(kosmos) { + testScope.runTest { + // GIVEN an effect with a testing scope + longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + // GIVEN the TIMEOUT_WAIT state is entered + val downEvent = + MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_DOWN).build() + longPressEffect.onTouch(testView, downEvent) + + // THEN run the test + test() + } + } + + private fun testWhileRunning(test: suspend TestScope.() -> Unit) = + with(kosmos) { + testScope.runTest { + // GIVEN an effect with a testing scope + longPressEffect.scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + // GIVEN the down event that enters the TIMEOUT_WAIT state + val downEvent = + MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_DOWN).build() + longPressEffect.onTouch(testView, downEvent) + + // GIVEN that the timeout completes and the effect starts + advanceTimeBy(QSLongPressEffect.PRESSED_TIMEOUT + 10L) + + // THEN run the test + test() + } + } + + /** + * Asserts that the effect started by checking that: + * 1. The effect progress is 0f + * 2. Initial hint haptics are played + * 3. The internal state is [QSLongPressEffect.State.RUNNING_FORWARD] + */ + private fun TestScope.assertEffectStarted() { + val effectProgress by collectLastValue(longPressEffect.effectProgress) + val longPressHint = + LongPressHapticBuilder.createLongPressHint( + lowTickDuration, + spinDuration, + effectDuration, + ) + + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) + assertThat(effectProgress).isEqualTo(0f) + assertThat(longPressHint).isNotNull() + verify(vibratorHelper).vibrate(longPressHint!!) + } + + /** + * Asserts that the effect did not start by checking that: + * 1. No effect progress is emitted + * 2. No haptics are played + * 3. The internal state is not [QSLongPressEffect.State.RUNNING_BACKWARDS] or + * [QSLongPressEffect.State.RUNNING_FORWARD] + */ + private fun TestScope.assertEffectDidNotStart() { + val effectProgress by collectLastValue(longPressEffect.effectProgress) + + assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_FORWARD) + assertThat(longPressEffect.state).isNotEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) + assertThat(effectProgress).isNull() + verify(vibratorHelper, never()).vibrate(any(/* type= */ VibrationEffect::class.java)) + } + + /** + * Asserts that the effect completes by checking that: + * 1. The progress is null + * 2. The final snap haptics are played + * 3. The internal state goes back to [QSLongPressEffect.State.IDLE] + * 4. The action to perform on the tile is the long-press action + */ + private fun TestScope.assertEffectCompleted() { + val action by collectLastValue(longPressEffect.actionType) + val effectProgress by collectLastValue(longPressEffect.effectProgress) + val snapEffect = LongPressHapticBuilder.createSnapEffect() + + assertThat(effectProgress).isNull() + assertThat(snapEffect).isNotNull() + verify(vibratorHelper).vibrate(snapEffect!!) + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.IDLE) + assertThat(action).isEqualTo(QSLongPressEffect.ActionType.LONG_PRESS) + } + + /** + * Assert that the effect gets reverted by checking that: + * 1. The internal state is [QSLongPressEffect.State.RUNNING_BACKWARDS] + * 2. The reverse haptics plays at the point where the animation was paused + */ + private fun assertEffectReverses(pausedProgress: Float) { + val reverseHaptics = + LongPressHapticBuilder.createReversedEffect( + pausedProgress, + lowTickDuration, + effectDuration, + ) + + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.RUNNING_BACKWARDS) + assertThat(reverseHaptics).isNotNull() + verify(vibratorHelper).vibrate(reverseHaptics!!) + } + + /** + * Asserts that the effect resets by checking that: + * 1. The effect progress resets to 0 + * 2. The internal state goes back to [QSLongPressEffect.State.TIMEOUT_WAIT] + */ + private fun TestScope.assertEffectResets() { + val effectProgress by collectLastValue(longPressEffect.effectProgress) + assertThat(effectProgress).isEqualTo(0f) + + assertThat(longPressEffect.state).isEqualTo(QSLongPressEffect.State.TIMEOUT_WAIT) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/qs/LongPressHapticBuilder.kt b/packages/SystemUI/src/com/android/systemui/haptics/qs/LongPressHapticBuilder.kt new file mode 100644 index 000000000000..0143b85a4fbf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/qs/LongPressHapticBuilder.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.qs + +import android.os.VibrationEffect +import android.util.Log +import kotlin.math.max + +object LongPressHapticBuilder { + + const val INVALID_DURATION = 0 /* in ms */ + + private const val TAG = "LongPressHapticBuilder" + private const val SPIN_SCALE = 0.2f + private const val CLICK_SCALE = 0.5f + private const val LOW_TICK_SCALE = 0.08f + private const val WARMUP_TIME = 75 /* in ms */ + private const val DAMPING_TIME = 24 /* in ms */ + + /** Create the signal that indicates that a long-press action is available. */ + fun createLongPressHint( + lowTickDuration: Int, + spinDuration: Int, + effectDuration: Int + ): VibrationEffect? { + if (lowTickDuration == 0 || spinDuration == 0) { + Log.d( + TAG, + "The LOW_TICK and/or SPIN primitives are not supported. No signal created.", + ) + return null + } + if (effectDuration < WARMUP_TIME + spinDuration + DAMPING_TIME) { + Log.d( + TAG, + "Cannot fit long-press hint signal in the effect duration. No signal created", + ) + return null + } + + val nLowTicks = WARMUP_TIME / lowTickDuration + val rampDownLowTicks = DAMPING_TIME / lowTickDuration + val composition = VibrationEffect.startComposition() + + // Warmup low ticks + repeat(nLowTicks) { + composition.addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + LOW_TICK_SCALE, + 0, + ) + } + + // Spin effect + composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, SPIN_SCALE, 0) + + // Damping low ticks + repeat(rampDownLowTicks) { i -> + composition.addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + LOW_TICK_SCALE / (i + 1), + 0, + ) + } + + return composition.compose() + } + + /** Create a "snapping" effect that triggers at the end of a long-press gesture */ + fun createSnapEffect(): VibrationEffect? = + VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, CLICK_SCALE, 0) + .compose() + + /** Creates a signal that indicates the reversal of the long-press animation. */ + fun createReversedEffect( + pausedProgress: Float, + lowTickDuration: Int, + effectDuration: Int, + ): VibrationEffect? { + val duration = pausedProgress * effectDuration + if (duration == 0f) return null + + if (lowTickDuration == 0) { + Log.d(TAG, "Cannot play reverse haptics because LOW_TICK is not supported") + return null + } + + val nLowTicks = (duration / lowTickDuration).toInt() + if (nLowTicks == 0) return null + + val composition = VibrationEffect.startComposition() + var scale: Float + val step = LOW_TICK_SCALE / nLowTicks + repeat(nLowTicks) { i -> + scale = max(LOW_TICK_SCALE - step * i, 0f) + composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, scale, 0) + } + return composition.compose() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt new file mode 100644 index 000000000000..ec72a1422973 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.qs + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.os.VibrationEffect +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.annotation.VisibleForTesting +import androidx.core.animation.doOnCancel +import androidx.core.animation.doOnEnd +import androidx.core.animation.doOnStart +import com.android.systemui.statusbar.VibratorHelper +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * A class that handles the long press visuo-haptic effect for a QS tile. + * + * The class is also a [View.OnTouchListener] to handle the touch events, clicks and long-press + * gestures of the tile. The class also provides a [State] that can be used to determine the current + * state of the long press effect. + * + * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects. + * @property[effectDuration] The duration of the effect in ms. + */ +class QSLongPressEffect( + private val vibratorHelper: VibratorHelper?, + private val effectDuration: Int, +) : View.OnTouchListener { + + /** Current state */ + var state = State.IDLE + @VisibleForTesting set + + /** Flows for view control and action */ + private val _effectProgress = MutableStateFlow<Float?>(null) + val effectProgress = _effectProgress.asStateFlow() + + private val _actionType = MutableStateFlow<ActionType?>(null) + val actionType = _actionType.asStateFlow() + + /** Haptic effects */ + private val durations = + vibratorHelper?.getPrimitiveDurations( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, + VibrationEffect.Composition.PRIMITIVE_SPIN + ) + + private val longPressHint = + LongPressHapticBuilder.createLongPressHint( + durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION, + durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION, + effectDuration + ) + + private val snapEffect = LongPressHapticBuilder.createSnapEffect() + + /* A coroutine scope and a timer job that waits for the pressedTimeout */ + var scope: CoroutineScope? = null + private var waitJob: Job? = null + + private val effectAnimator = + ValueAnimator.ofFloat(0f, 1f).apply { + duration = effectDuration.toLong() + interpolator = AccelerateDecelerateInterpolator() + + doOnStart { handleAnimationStart() } + addUpdateListener { _effectProgress.value = animatedValue as Float } + doOnEnd { handleAnimationComplete() } + doOnCancel { handleAnimationCancel() } + } + + private fun reverse() { + val pausedProgress = effectAnimator.animatedFraction + val effect = + LongPressHapticBuilder.createReversedEffect( + pausedProgress, + durations?.get(0) ?: 0, + effectDuration, + ) + vibratorHelper?.cancel() + vibrate(effect) + effectAnimator.reverse() + } + + private fun vibrate(effect: VibrationEffect?) { + if (vibratorHelper != null && effect != null) { + vibratorHelper.vibrate(effect) + } + } + + /** + * Handle relevant touch events for the operation of a Tile. + * + * A click action is performed following the relevant logic that originates from the + * [MotionEvent.ACTION_UP] event depending on the current state. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(view: View?, event: MotionEvent?): Boolean { + when (event?.actionMasked) { + MotionEvent.ACTION_DOWN -> handleActionDown() + MotionEvent.ACTION_UP -> handleActionUp() + MotionEvent.ACTION_CANCEL -> handleActionCancel() + } + return true + } + + private fun handleActionDown() { + when (state) { + State.IDLE -> { + startPressedTimeoutWait() + state = State.TIMEOUT_WAIT + } + State.RUNNING_BACKWARDS -> effectAnimator.cancel() + else -> {} + } + } + + private fun startPressedTimeoutWait() { + waitJob = + scope?.launch { + try { + delay(PRESSED_TIMEOUT) + handleTimeoutComplete() + } catch (_: CancellationException) { + state = State.IDLE + } + } + } + + private fun handleActionUp() { + when (state) { + State.TIMEOUT_WAIT -> { + waitJob?.cancel() + _actionType.value = ActionType.CLICK + state = State.IDLE + } + State.RUNNING_FORWARD -> { + reverse() + state = State.RUNNING_BACKWARDS + } + else -> {} + } + } + + private fun handleActionCancel() { + when (state) { + State.TIMEOUT_WAIT -> { + waitJob?.cancel() + state = State.IDLE + } + State.RUNNING_FORWARD -> { + reverse() + state = State.RUNNING_BACKWARDS + } + else -> {} + } + } + + private fun handleAnimationStart() { + vibrate(longPressHint) + state = State.RUNNING_FORWARD + } + + /** This function is called both when an animator completes or gets cancelled */ + private fun handleAnimationComplete() { + if (state == State.RUNNING_FORWARD) { + vibrate(snapEffect) + _actionType.value = ActionType.LONG_PRESS + _effectProgress.value = null + } + if (state != State.TIMEOUT_WAIT) { + // This will happen if the animator did not finish by being cancelled + state = State.IDLE + } + } + + private fun handleAnimationCancel() { + _effectProgress.value = 0f + startPressedTimeoutWait() + state = State.TIMEOUT_WAIT + } + + private fun handleTimeoutComplete() { + if (state == State.TIMEOUT_WAIT && !effectAnimator.isRunning) { + effectAnimator.start() + } + } + + fun clearActionType() { + _actionType.value = null + } + + enum class State { + IDLE, /* The effect is idle waiting for touch input */ + TIMEOUT_WAIT, /* The effect is waiting for a [PRESSED_TIMEOUT] period */ + RUNNING_FORWARD, /* The effect is running normally */ + RUNNING_BACKWARDS, /* The effect was interrupted and is now running backwards */ + } + + /* A type of action to perform on the view depending on the effect's state and logic */ + enum class ActionType { + CLICK, + LONG_PRESS, + } + + companion object { + /** + * A timeout to let the tile resolve if it is being swiped/scrolled. Since QS tiles are + * inside a scrollable container, they will be considered pressed only after a tap timeout. + */ + val PRESSED_TIMEOUT = ViewConfiguration.getTapTimeout().toLong() + 20L + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt new file mode 100644 index 000000000000..e298154159b2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt @@ -0,0 +1,62 @@ +/* + * 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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.qs.tileimpl.QSTileViewImpl +import kotlinx.coroutines.launch + +object QSLongPressEffectViewBinder { + + fun bind( + tile: QSTileViewImpl, + effect: QSLongPressEffect?, + ) { + if (effect == null) return + + tile.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.STARTED) { + effect.scope = this + + launch { + effect.effectProgress.collect { progress -> + progress?.let { + if (it == 0f) { + tile.bringToFront() + } + tile.updateLongPressEffectProperties(it) + } + } + } + + launch { + effect.actionType.collect { action -> + action?.let { + when (it) { + QSLongPressEffect.ActionType.CLICK -> tile.performClick() + QSLongPressEffect.ActionType.LONG_PRESS -> tile.performLongClick() + } + effect.clearActionType() + } + } + } + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java index 2440651555d7..cd6511979375 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java @@ -38,6 +38,7 @@ import com.android.systemui.scene.shared.flag.SceneContainerFlags; 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.statusbar.VibratorHelper; import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager; import com.android.systemui.statusbar.policy.BrightnessMirrorController; import com.android.systemui.statusbar.policy.SplitShadeStateController; @@ -90,9 +91,11 @@ public class QSPanelController extends QSPanelControllerBase<QSPanel> { FalsingManager falsingManager, StatusBarKeyguardViewManager statusBarKeyguardViewManager, SplitShadeStateController splitShadeStateController, - SceneContainerFlags sceneContainerFlags) { + SceneContainerFlags sceneContainerFlags, + VibratorHelper vibratorHelper) { super(view, qsHost, qsCustomizerController, usingMediaPlayer, mediaHost, - metricsLogger, uiEventLogger, qsLogger, dumpManager, splitShadeStateController); + metricsLogger, uiEventLogger, qsLogger, dumpManager, splitShadeStateController, + vibratorHelper); 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 975c871bd006..5e12b9d4cc34 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java @@ -39,6 +39,7 @@ 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; @@ -87,6 +88,8 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr private SplitShadeStateController mSplitShadeStateController; + private final VibratorHelper mVibratorHelper; + @VisibleForTesting protected final QSPanel.OnConfigurationChangedListener mOnConfigurationChangedListener = new QSPanel.OnConfigurationChangedListener() { @@ -144,7 +147,8 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr UiEventLogger uiEventLogger, QSLogger qsLogger, DumpManager dumpManager, - SplitShadeStateController splitShadeStateController + SplitShadeStateController splitShadeStateController, + VibratorHelper vibratorHelper ) { super(view); mHost = host; @@ -158,6 +162,7 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr mSplitShadeStateController = splitShadeStateController; mShouldUseSplitNotificationShade = mSplitShadeStateController.shouldUseSplitNotificationShade(getResources()); + mVibratorHelper = vibratorHelper; } @Override @@ -300,7 +305,8 @@ public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewContr } private void addTile(final QSTile tile, boolean collapsedView) { - final QSTileViewImpl tileView = new QSTileViewImpl(getContext(), collapsedView); + final QSTileViewImpl tileView = new QSTileViewImpl( + getContext(), collapsedView, mVibratorHelper); 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 a8e88da5d288..05bb08813cc5 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java @@ -32,6 +32,7 @@ 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; @@ -56,10 +57,11 @@ public class QuickQSPanelController extends QSPanelControllerBase<QuickQSPanel> @Named(QS_USING_COLLAPSED_LANDSCAPE_MEDIA) Provider<Boolean> usingCollapsedLandscapeMediaProvider, MetricsLogger metricsLogger, UiEventLogger uiEventLogger, QSLogger qsLogger, - DumpManager dumpManager, SplitShadeStateController splitShadeStateController + DumpManager dumpManager, SplitShadeStateController splitShadeStateController, + VibratorHelper vibratorHelper ) { super(view, qsHost, qsCustomizerController, usingMediaPlayer, mediaHost, metricsLogger, - uiEventLogger, qsLogger, dumpManager, splitShadeStateController); + uiEventLogger, qsLogger, dumpManager, splitShadeStateController, vibratorHelper); mUsingCollapsedLandscapeMediaProvider = usingCollapsedLandscapeMediaProvider; } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSLongPressProperties.kt b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSLongPressProperties.kt new file mode 100644 index 000000000000..a2ded6a6aacf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSLongPressProperties.kt @@ -0,0 +1,34 @@ +/* + * 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.qs.tileimpl + +/** + * List of properties that define the state of a tile during a long-press gesture. + * + * These properties are used during animation if a tile supports a long-press action. + */ +data class QSLongPressProperties( + var xScale: Float, + var yScale: Float, + var cornerRadius: Float, + var backgroundColor: Int, + var labelColor: Int, + var secondaryLabelColor: Int, + var chevronColor: Int, + var overlayColor: Int, + var iconColor: Int, +) 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 6cc682ae3c96..63963ded2923 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt @@ -26,6 +26,7 @@ import android.content.res.Resources.ID_NULL import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable import android.os.Trace @@ -36,6 +37,7 @@ import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -48,9 +50,12 @@ import androidx.annotation.VisibleForTesting import com.android.app.tracing.traceSection import com.android.settingslib.Utils import com.android.systemui.Flags +import com.android.systemui.Flags.quickSettingsVisualHapticsLongpress import com.android.systemui.FontSizeUtils import com.android.systemui.animation.LaunchableView import com.android.systemui.animation.LaunchableViewDelegate +import com.android.systemui.haptics.qs.QSLongPressEffect +import com.android.systemui.haptics.qs.QSLongPressEffectViewBinder import com.android.systemui.plugins.qs.QSIconView import com.android.systemui.plugins.qs.QSTile import com.android.systemui.plugins.qs.QSTile.AdapterState @@ -58,12 +63,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 java.util.Objects private const val TAG = "QSTileViewImpl" open class QSTileViewImpl @JvmOverloads constructor( context: Context, - private val collapsed: Boolean = false + private val collapsed: Boolean = false, + private val vibratorHelper: VibratorHelper? = null, ) : QSTileView(context), HeightOverrideable, LaunchableView { companion object { @@ -163,6 +171,7 @@ open class QSTileViewImpl @JvmOverloads constructor( private var lastStateDescription: CharSequence? = null private var tileState = false private var lastState = INVALID + private var lastIconTint = 0 private val launchableViewDelegate = LaunchableViewDelegate( this, superSetVisibility = { super.setVisibility(it) }, @@ -171,6 +180,12 @@ open class QSTileViewImpl @JvmOverloads constructor( private val locInScreen = IntArray(2) + /** Visuo-haptic long-press effects */ + private var longPressEffect: QSLongPressEffect? = null + private var initialLongPressProperties: QSLongPressProperties? = null + private var finalLongPressProperties: QSLongPressProperties? = null + private val colorEvaluator = ArgbEvaluator.getInstance() + init { val typedValue = TypedValue() if (!getContext().theme.resolveAttribute(R.attr.isQsTheme, typedValue, true)) { @@ -339,6 +354,9 @@ open class QSTileViewImpl @JvmOverloads constructor( true } ) + if (quickSettingsVisualHapticsLongpress()) { + isHapticFeedbackEnabled = false // Haptics will be handled by the [QSLongPressEffect] + } } private fun init( @@ -589,6 +607,27 @@ open class QSTileViewImpl @JvmOverloads constructor( lastState = state.state lastDisabledByPolicy = state.disabledByPolicy + lastIconTint = icon.getColor(state) + + // Long-press effects + if (quickSettingsVisualHapticsLongpress()){ + if (state.handlesLongClick) { + // initialize the long-press effect and set it as the touch listener + showRippleEffect = false + initializeLongPressEffect() + setOnTouchListener(longPressEffect) + QSLongPressEffectViewBinder.bind(this, 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 + showRippleEffect = isClickable + setOnTouchListener(null) + longPressEffect = null + initialLongPressProperties = null + finalLongPressProperties = null + } + } } private fun setAllColors( @@ -709,6 +748,140 @@ open class QSTileViewImpl @JvmOverloads constructor( } } + override fun onActivityLaunchAnimationEnd() = resetLongPressEffectProperties() + + fun updateLongPressEffectProperties(effectProgress: Float) { + if (!isLongClickable) return + setAllColors( + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.backgroundColor ?: 0, + finalLongPressProperties?.backgroundColor ?: 0, + ) as Int, + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.labelColor ?: 0, + finalLongPressProperties?.labelColor ?: 0, + ) as Int, + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.secondaryLabelColor ?: 0, + finalLongPressProperties?.secondaryLabelColor ?: 0, + ) as Int, + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.chevronColor ?: 0, + finalLongPressProperties?.chevronColor ?: 0, + ) as Int, + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.overlayColor ?: 0, + finalLongPressProperties?.overlayColor ?: 0, + ) as Int, + ) + icon.setTint( + icon.mIcon as ImageView, + colorEvaluator.evaluate( + effectProgress, + initialLongPressProperties?.iconColor ?: 0, + finalLongPressProperties?.iconColor ?: 0, + ) as Int, + ) + + val newScaleX = + interpolateFloat( + effectProgress, + initialLongPressProperties?.xScale ?: 1f, + finalLongPressProperties?.xScale ?: 1f, + ) + val newScaleY = + interpolateFloat( + effectProgress, + initialLongPressProperties?.xScale ?: 1f, + finalLongPressProperties?.xScale ?: 1f, + ) + val newRadius = + interpolateFloat( + effectProgress, + initialLongPressProperties?.cornerRadius ?: 0f, + finalLongPressProperties?.cornerRadius ?: 0f, + ) + scaleX = newScaleX + scaleY = newScaleY + for (child in children) { + child.scaleX = 1f / newScaleX + child.scaleY = 1f / newScaleY + } + changeCornerRadius(newRadius) + } + + private fun interpolateFloat(fraction: Float, start: Float, end: Float): Float = + start + fraction * (end - start) + + private fun resetLongPressEffectProperties() { + scaleY = 1f + scaleX = 1f + for (child in children) { + child.scaleY = 1f + child.scaleX = 1f + } + changeCornerRadius(resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat()) + setAllColors( + getBackgroundColorForState(lastState, lastDisabledByPolicy), + getLabelColorForState(lastState, lastDisabledByPolicy), + getSecondaryLabelColorForState(lastState, lastDisabledByPolicy), + getChevronColorForState(lastState, lastDisabledByPolicy), + getOverlayColorForState(lastState), + ) + icon.setTint(icon.mIcon as ImageView, lastIconTint) + } + + private fun initializeLongPressEffect() { + initializeLongPressProperties() + longPressEffect = + QSLongPressEffect( + vibratorHelper, + ViewConfiguration.getLongPressTimeout() - ViewConfiguration.getTapTimeout(), + ) + } + + private fun initializeLongPressProperties() { + initialLongPressProperties = + QSLongPressProperties( + /* xScale= */1f, + /* yScale= */1f, + resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat(), + getBackgroundColorForState(lastState), + getLabelColorForState(lastState), + getSecondaryLabelColorForState(lastState), + getChevronColorForState(lastState), + getOverlayColorForState(lastState), + lastIconTint, + ) + + finalLongPressProperties = + QSLongPressProperties( + /* xScale= */1.1f, + /* yScale= */1.2f, + resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat() - 20, + getBackgroundColorForState(Tile.STATE_ACTIVE), + getLabelColorForState(Tile.STATE_ACTIVE), + getSecondaryLabelColorForState(Tile.STATE_ACTIVE), + getChevronColorForState(Tile.STATE_ACTIVE), + getOverlayColorForState(Tile.STATE_ACTIVE), + Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive), + ) + } + + private fun changeCornerRadius(radius: Float) { + for (i in 0 until backgroundDrawable.numberOfLayers) { + val layer = backgroundDrawable.getDrawable(i) + if (layer is GradientDrawable) { + layer.cornerRadius = radius + } + } + } + @VisibleForTesting internal fun getCurrentColors(): List<Int> = listOf( backgroundColor, 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 65ede89a1514..0101741a9242 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java @@ -51,6 +51,7 @@ 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; @@ -100,6 +101,8 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { Configuration mConfiguration; @Mock Runnable mHorizontalLayoutListener; + @Mock + VibratorHelper mVibratorHelper; private QSPanelControllerBase<QSPanel> mController; @@ -110,7 +113,8 @@ public class QSPanelControllerBaseTest extends SysuiTestCase { MetricsLogger metricsLogger, UiEventLogger uiEventLogger, QSLogger qsLogger, DumpManager dumpManager) { super(view, host, qsCustomizerController, true, mediaHost, metricsLogger, uiEventLogger, - qsLogger, dumpManager, new ResourcesSplitShadeStateController()); + qsLogger, dumpManager, new ResourcesSplitShadeStateController(), + mVibratorHelper); } @Override 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 85d7d9865c7c..916e8ddb6e8a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt @@ -19,6 +19,7 @@ import com.android.systemui.qs.logging.QSLogger 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 @@ -61,6 +62,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 private val sceneContainerFlags = FakeSceneContainerFlags() @@ -101,6 +103,7 @@ class QSPanelControllerTest : SysuiTestCase() { statusBarKeyguardViewManager, ResourcesSplitShadeStateController(), sceneContainerFlags, + vibratorHelper, ) } 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 2c1430844d12..71a9a8b3318f 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt @@ -30,6 +30,7 @@ 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.statusbar.policy.ResourcesSplitShadeStateController import com.android.systemui.util.leak.RotationUtils import org.junit.After @@ -59,6 +60,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 private val uiEventLogger = UiEventLoggerFake() private val dumpManager = DumpManager() @@ -89,7 +91,8 @@ class QuickQSPanelControllerTest : SysuiTestCase() { metricsLogger, uiEventLogger, qsLogger, - dumpManager + dumpManager, + vibratorHelper, ) controller.init() @@ -157,7 +160,8 @@ class QuickQSPanelControllerTest : SysuiTestCase() { metricsLogger: MetricsLogger, uiEventLogger: UiEventLoggerFake, qsLogger: QSLogger, - dumpManager: DumpManager + dumpManager: DumpManager, + vibratorHelper: VibratorHelper, ) : QuickQSPanelController( view, @@ -170,7 +174,8 @@ class QuickQSPanelControllerTest : SysuiTestCase() { uiEventLogger, qsLogger, dumpManager, - ResourcesSplitShadeStateController() + ResourcesSplitShadeStateController(), + vibratorHelper, ) { private var rotation = RotationUtils.ROTATION_NONE |