summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Juan Sebastian Martinez <juansmartinez@google.com> 2024-03-12 14:09:31 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2024-03-12 14:09:31 +0000
commit5285cdb846a19b935de3973e51ba963ea0d6c2f6 (patch)
tree08575bf45b8e0574e1856e5b90c4fe1b9c103b28
parent2abfd31296e60bec2f510e7e53f6a7700952e545 (diff)
parentf6b248b09ea4ac50475c0aa9efbf28206f7c58f0 (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.
-rw-r--r--packages/SystemUI/animation/src/com/android/systemui/animation/GhostedViewTransitionAnimatorController.kt1
-rw-r--r--packages/SystemUI/animation/src/com/android/systemui/animation/LaunchableView.kt3
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/qs/QSLongPressEffectTest.kt332
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/qs/LongPressHapticBuilder.kt115
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffect.kt237
-rw-r--r--packages/SystemUI/src/com/android/systemui/haptics/qs/QSLongPressEffectViewBinder.kt62
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/QSPanelController.java7
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/QSPanelControllerBase.java10
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/QuickQSPanelController.java6
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSLongPressProperties.kt34
-rw-r--r--packages/SystemUI/src/com/android/systemui/qs/tileimpl/QSTileViewImpl.kt175
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerBaseTest.java6
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelControllerTest.kt3
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/qs/QuickQSPanelControllerTest.kt11
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