1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.haptics.qs
18 
19 import android.os.VibrationEffect
20 import androidx.annotation.VisibleForTesting
21 import com.android.systemui.animation.Expandable
22 import com.android.systemui.plugins.qs.QSTile
23 import com.android.systemui.statusbar.VibratorHelper
24 import com.android.systemui.statusbar.policy.KeyguardStateController
25 import javax.inject.Inject
26 
27 /**
28  * A class that handles the long press visuo-haptic effect for a QS tile.
29  *
30  * The class can contain references to a [QSTile] and an [Expandable] to perform clicks and
31  * long-clicks on the tile. The class also provides a [State] tha can be used to determine the
32  * current state of the long press effect.
33  *
34  * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects.
35  * @property[effectDuration] The duration of the effect in ms.
36  */
37 // TODO(b/332902869): In addition from being injectable, we can consider making it a singleton
38 class QSLongPressEffect
39 @Inject
40 constructor(
41     private val vibratorHelper: VibratorHelper?,
42     private val keyguardStateController: KeyguardStateController,
43 ) {
44 
45     var effectDuration = 0
46         private set
47 
48     /** Current state */
49     var state = State.IDLE
50         private set
51 
52     /** Callback object for effect actions */
53     var callback: Callback? = null
54 
55     /** The [QSTile] and [Expandable] used to perform a long-click and click actions */
56     var qsTile: QSTile? = null
57     var expandable: Expandable? = null
58 
59     /** Haptic effects */
60     private val durations =
61         vibratorHelper?.getPrimitiveDurations(
62             VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
63             VibrationEffect.Composition.PRIMITIVE_SPIN
64         )
65 
66     private var longPressHint: VibrationEffect? = null
67 
68     private val snapEffect = LongPressHapticBuilder.createSnapEffect()
69 
70     val hasInitialized: Boolean
71         get() = longPressHint != null
72 
73     @VisibleForTesting
setStatenull74     fun setState(newState: State) {
75         state = newState
76     }
77 
playReverseHapticsnull78     fun playReverseHaptics(pausedProgress: Float) {
79         val effect =
80             LongPressHapticBuilder.createReversedEffect(
81                 pausedProgress,
82                 durations?.get(0) ?: 0,
83                 effectDuration,
84             )
85         vibratorHelper?.cancel()
86         vibrate(effect)
87     }
88 
vibratenull89     private fun vibrate(effect: VibrationEffect?) {
90         if (vibratorHelper != null && effect != null) {
91             vibratorHelper.vibrate(effect)
92         }
93     }
94 
handleActionDownnull95     fun handleActionDown() {
96         when (state) {
97             State.IDLE -> {
98                 setState(State.TIMEOUT_WAIT)
99             }
100             State.RUNNING_BACKWARDS -> callback?.onCancelAnimator()
101             else -> {}
102         }
103     }
104 
handleActionUpnull105     fun handleActionUp() {
106         if (state == State.RUNNING_FORWARD) {
107             setState(State.RUNNING_BACKWARDS)
108             callback?.onReverseAnimator()
109         }
110     }
111 
handleActionCancelnull112     fun handleActionCancel() {
113         when (state) {
114             State.TIMEOUT_WAIT -> setState(State.IDLE)
115             State.RUNNING_FORWARD -> {
116                 setState(State.RUNNING_BACKWARDS)
117                 callback?.onReverseAnimator()
118             }
119             else -> {}
120         }
121     }
122 
handleAnimationStartnull123     fun handleAnimationStart() {
124         vibrate(longPressHint)
125         setState(State.RUNNING_FORWARD)
126     }
127 
128     /** This function is called both when an animator completes or gets cancelled */
handleAnimationCompletenull129     fun handleAnimationComplete() {
130         if (state == State.RUNNING_FORWARD) {
131             setState(State.IDLE)
132             vibrate(snapEffect)
133             if (keyguardStateController.isUnlocked) {
134                 callback?.onPrepareForLaunch()
135                 qsTile?.longClick(expandable)
136             } else {
137                 callback?.onResetProperties()
138                 qsTile?.longClick(expandable)
139             }
140         }
141         if (state != State.TIMEOUT_WAIT) {
142             // This will happen if the animator did not finish by being cancelled
143             setState(State.IDLE)
144         }
145     }
146 
handleAnimationCancelnull147     fun handleAnimationCancel() {
148         setState(State.TIMEOUT_WAIT)
149     }
150 
handleTimeoutCompletenull151     fun handleTimeoutComplete() {
152         if (state == State.TIMEOUT_WAIT) {
153             callback?.onStartAnimator()
154         }
155     }
156 
onTileClicknull157     fun onTileClick(): Boolean {
158         if (state == State.TIMEOUT_WAIT) {
159             setState(State.IDLE)
160             qsTile?.let {
161                 it.click(expandable)
162                 return true
163             }
164         }
165         return false
166     }
167 
168     /**
169      * Reset the effect with a new effect duration.
170      *
171      * @param[duration] New duration for the long-press effect
172      * @return true if the effect initialized correctly
173      */
initializeEffectnull174     fun initializeEffect(duration: Int): Boolean {
175         // The effect can't initialize with a negative duration
176         if (duration <= 0) return false
177 
178         // There is no need to re-initialize if the duration has not changed
179         if (duration == effectDuration) return true
180 
181         effectDuration = duration
182         longPressHint =
183             LongPressHapticBuilder.createLongPressHint(
184                 durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION,
185                 durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION,
186                 effectDuration
187             )
188         setState(State.IDLE)
189         return true
190     }
191 
192     enum class State {
193         IDLE, /* The effect is idle waiting for touch input */
194         TIMEOUT_WAIT, /* The effect is waiting for a [PRESSED_TIMEOUT] period */
195         RUNNING_FORWARD, /* The effect is running normally */
196         RUNNING_BACKWARDS, /* The effect was interrupted and is now running backwards */
197     }
198 
199     /** Callbacks to notify view and animator actions */
200     interface Callback {
201 
202         /** Prepare for an activity launch */
onPrepareForLaunchnull203         fun onPrepareForLaunch()
204 
205         /** Reset the tile visual properties */
206         fun onResetProperties()
207 
208         /** Start the effect animator */
209         fun onStartAnimator()
210 
211         /** Reverse the effect animator */
212         fun onReverseAnimator()
213 
214         /** Cancel the effect animator */
215         fun onCancelAnimator()
216     }
217 }
218