• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.content.ComponentName
20 import android.os.VibrationEffect
21 import android.service.quicksettings.Tile
22 import android.view.View
23 import androidx.annotation.VisibleForTesting
24 import com.android.systemui.animation.ActivityTransitionAnimator
25 import com.android.systemui.animation.DelegateTransitionAnimatorController
26 import com.android.systemui.animation.DialogCuj
27 import com.android.systemui.animation.DialogTransitionAnimator
28 import com.android.systemui.animation.Expandable
29 import com.android.systemui.log.LogBuffer
30 import com.android.systemui.log.core.LogLevel
31 import com.android.systemui.log.dagger.QSLog
32 import com.android.systemui.plugins.FalsingManager
33 import com.android.systemui.plugins.qs.QSTile
34 import com.android.systemui.statusbar.VibratorHelper
35 import com.android.systemui.statusbar.policy.KeyguardStateController
36 import javax.inject.Inject
37 
38 /**
39  * A class that handles the long press visuo-haptic effect for a QS tile.
40  *
41  * The class can contain references to a [QSTile] and an [Expandable] to perform clicks and
42  * long-clicks on the tile. The class also provides a [State] tha can be used to determine the
43  * current state of the long press effect.
44  *
45  * @property[vibratorHelper] The [VibratorHelper] to deliver haptic effects.
46  * @property[effectDuration] The duration of the effect in ms.
47  */
48 class QSLongPressEffect
49 @Inject
50 constructor(
51     private val vibratorHelper: VibratorHelper?,
52     private val keyguardStateController: KeyguardStateController,
53     private val falsingManager: FalsingManager,
54     @QSLog private val logBuffer: LogBuffer,
55 ) {
56 
57     var effectDuration = 0
58         private set
59 
60     /** Current state */
61     var state = State.IDLE
62         private set
63 
64     /** Callback object for effect actions */
65     var callback: Callback? = null
66 
67     /** The [QSTile] and [Expandable] used to perform a long-click and click actions */
68     var qsTile: QSTile? = null
69     var expandable: Expandable? = null
70         private set
71 
72     /** Haptic effects */
73     private val durations =
74         vibratorHelper?.getPrimitiveDurations(
75             VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
76             VibrationEffect.Composition.PRIMITIVE_SPIN,
77         )
78 
79     private var longPressHint: VibrationEffect? = null
80 
81     private val snapEffect = LongPressHapticBuilder.createSnapEffect()
82 
83     val hasInitialized: Boolean
84         get() = longPressHint != null
85 
86     @VisibleForTesting
setStatenull87     fun setState(newState: State) {
88         state = newState
89     }
90 
playReverseHapticsnull91     fun playReverseHaptics(pausedProgress: Float) {
92         val effect =
93             LongPressHapticBuilder.createReversedEffect(
94                 pausedProgress,
95                 durations?.get(0) ?: 0,
96                 effectDuration,
97             )
98         vibratorHelper?.cancel()
99         vibrate(effect)
100     }
101 
vibratenull102     private fun vibrate(effect: VibrationEffect?) {
103         if (vibratorHelper != null && effect != null) {
104             vibratorHelper.vibrate(effect)
105         }
106     }
107 
handleActionDownnull108     fun handleActionDown() {
109         logEvent(qsTile?.tileSpec, state, "action down received")
110         when (state) {
111             State.IDLE,
112             // ACTION_DOWN typically only happens in State.IDLE but including CLICKED and
113             // LONG_CLICKED just to be safe`b
114             State.CLICKED,
115             State.LONG_CLICKED -> {
116                 setState(State.TIMEOUT_WAIT)
117             }
118             State.RUNNING_BACKWARDS_FROM_UP,
119             State.RUNNING_BACKWARDS_FROM_CANCEL -> callback?.onCancelAnimator()
120             else -> {}
121         }
122     }
123 
handleActionUpnull124     fun handleActionUp() {
125         logEvent(qsTile?.tileSpec, state, "action up received")
126         if (state == State.RUNNING_FORWARD) {
127             setState(State.RUNNING_BACKWARDS_FROM_UP)
128             callback?.onReverseAnimator()
129         }
130     }
131 
handleActionCancelnull132     fun handleActionCancel() {
133         when (state) {
134             State.TIMEOUT_WAIT -> setState(State.IDLE)
135             State.RUNNING_FORWARD -> {
136                 setState(State.RUNNING_BACKWARDS_FROM_CANCEL)
137                 callback?.onReverseAnimator()
138             }
139             else -> {}
140         }
141     }
142 
handleAnimationStartnull143     fun handleAnimationStart() {
144         logEvent(qsTile?.tileSpec, state, "animation started")
145         if (state == State.TIMEOUT_WAIT) {
146             vibrate(longPressHint)
147             setState(State.RUNNING_FORWARD)
148         }
149     }
150 
151     /** This function is called both when an animator completes or gets cancelled */
handleAnimationCompletenull152     fun handleAnimationComplete() {
153         logEvent(qsTile?.tileSpec, state, "animation completed")
154         when (state) {
155             State.RUNNING_FORWARD -> {
156                 val wasFalseLongTap = falsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)
157                 if (wasFalseLongTap) {
158                     callback?.onResetProperties()
159                     setState(State.IDLE)
160                     logEvent(qsTile?.tileSpec, state, "false long click. No action triggered")
161                 } else if (keyguardStateController.isUnlocked) {
162                     vibrate(snapEffect)
163                     setState(State.LONG_CLICKED)
164                     qsTile?.longClick(expandable)
165                     logEvent(qsTile?.tileSpec, state, "long click action triggered")
166                 } else {
167                     vibrate(snapEffect)
168                     callback?.onResetProperties()
169                     setState(State.IDLE)
170                     qsTile?.longClick(expandable)
171                     logEvent(
172                         qsTile?.tileSpec,
173                         state,
174                         "properties reset and long click action triggered",
175                     )
176                 }
177             }
178             State.RUNNING_BACKWARDS_FROM_UP -> {
179                 callback?.onEffectFinishedReversing()
180                 setState(getStateForClick())
181                 logEvent(
182                     qsTile?.tileSpec,
183                     state,
184                     "click action triggered from handleAnimationComplete",
185                 )
186                 qsTile?.click(expandable)
187             }
188             State.RUNNING_BACKWARDS_FROM_CANCEL -> {
189                 callback?.onEffectFinishedReversing()
190                 setState(State.IDLE)
191             }
192             else -> {}
193         }
194     }
195 
handleAnimationCancelnull196     fun handleAnimationCancel() {
197         setState(State.TIMEOUT_WAIT)
198     }
199 
handleTimeoutCompletenull200     fun handleTimeoutComplete() {
201         if (state == State.TIMEOUT_WAIT) {
202             callback?.onStartAnimator()
203         }
204     }
205 
onTileClicknull206     fun onTileClick(): Boolean {
207         val isStateClickable = state == State.TIMEOUT_WAIT || state == State.IDLE
208 
209         // Ignore View-generated clicks on invalid states or if the bouncer is showing
210         if (keyguardStateController.isPrimaryBouncerShowing || !isStateClickable) return false
211 
212         setState(getStateForClick())
213         logEvent(qsTile?.tileSpec, state, "click action triggered from onTileClick")
214         qsTile?.click(expandable)
215         return true
216     }
217 
onTileLongClicknull218     fun onTileLongClick(): Boolean {
219         if (state == State.IDLE) {
220             // This case represents a long-click detected outside of the QSLongPressEffect. This can
221             // be due to accessibility services
222             qsTile?.longClick(expandable)
223             logEvent(
224                 qsTile?.tileSpec,
225                 state,
226                 "long click action triggered from OnLongClickListener",
227             )
228             return true
229         }
230         return false
231     }
232 
233     /**
234      * Get the appropriate state for a click action.
235      *
236      * In some occasions, the click action will not result in a subsequent action that resets the
237      * state upon completion (e.g., a launch transition animation). In these cases, the state needs
238      * to be reset before the click is dispatched.
239      */
240     @VisibleForTesting
getStateForClicknull241     fun getStateForClick(): State {
242         val isTileUnavailable = qsTile?.state?.state == Tile.STATE_UNAVAILABLE
243         val handlesLongClick = qsTile?.state?.handlesLongClick == true
244         return if (isTileUnavailable || !handlesLongClick || keyguardStateController.isShowing) {
245             // The click event will not perform an action that resets the state. Therefore, this is
246             // the last opportunity to reset the state back to IDLE.
247             State.IDLE
248         } else {
249             State.CLICKED
250         }
251     }
252 
253     /**
254      * Reset the effect with a new effect duration.
255      *
256      * @param[duration] New duration for the long-press effect
257      * @return true if the effect initialized correctly
258      */
initializeEffectnull259     fun initializeEffect(duration: Int): Boolean {
260         // The effect can't initialize with a negative duration
261         if (duration <= 0) return false
262 
263         // There is no need to re-initialize if the duration has not changed
264         if (duration == effectDuration) return true
265 
266         effectDuration = duration
267         longPressHint =
268             LongPressHapticBuilder.createLongPressHint(
269                 durations?.get(0) ?: LongPressHapticBuilder.INVALID_DURATION,
270                 durations?.get(1) ?: LongPressHapticBuilder.INVALID_DURATION,
271                 effectDuration,
272             )
273         setState(State.IDLE)
274         return true
275     }
276 
resetStatenull277     fun resetState() = setState(State.IDLE)
278 
279     fun createExpandableFromView(view: View) {
280         expandable =
281             object : Expandable {
282                 override fun activityTransitionController(
283                     launchCujType: Int?,
284                     cookie: ActivityTransitionAnimator.TransitionCookie?,
285                     component: ComponentName?,
286                     returnCujType: Int?,
287                     isEphemeral: Boolean,
288                 ): ActivityTransitionAnimator.Controller? {
289                     val delegatedController =
290                         ActivityTransitionAnimator.Controller.fromView(
291                             view,
292                             launchCujType,
293                             cookie,
294                             component,
295                             returnCujType,
296                             isEphemeral,
297                         )
298                     return delegatedController?.let { createTransitionControllerDelegate(it) }
299                 }
300 
301                 override fun dialogTransitionController(
302                     cuj: DialogCuj?
303                 ): DialogTransitionAnimator.Controller? =
304                     DialogTransitionAnimator.Controller.fromView(view, cuj)
305             }
306     }
307 
308     @VisibleForTesting
createTransitionControllerDelegatenull309     fun createTransitionControllerDelegate(
310         controller: ActivityTransitionAnimator.Controller
311     ): DelegateTransitionAnimatorController {
312         val delegated =
313             object : DelegateTransitionAnimatorController(controller) {
314                 override fun onTransitionAnimationCancelled(newKeyguardOccludedState: Boolean?) {
315                     if (state == State.LONG_CLICKED) {
316                         setState(State.RUNNING_BACKWARDS_FROM_CANCEL)
317                         callback?.onReverseAnimator(false)
318                     }
319                     delegate.onTransitionAnimationCancelled(newKeyguardOccludedState)
320                 }
321             }
322         return delegated
323     }
324 
logEventnull325     private fun logEvent(tileSpec: String?, state: State, event: String) {
326         if (!DEBUG) return
327         logBuffer.log(
328             TAG,
329             LogLevel.DEBUG,
330             {
331                 str1 = tileSpec
332                 str2 = event
333                 str3 = state.name
334             },
335             { "[long-press effect on $str1 tile] $str2 on state: $str3" },
336         )
337     }
338 
339     enum class State {
340         IDLE, /* The effect is idle waiting for touch input */
341         TIMEOUT_WAIT, /* The effect is waiting for a tap timeout period */
342         RUNNING_FORWARD, /* The effect is running normally */
343         /* The effect was interrupted by an ACTION_UP and is now running backwards */
344         RUNNING_BACKWARDS_FROM_UP,
345         /* The effect was cancelled by an ACTION_CANCEL or a shade collapse and is now running
346         backwards */
347         RUNNING_BACKWARDS_FROM_CANCEL,
348         CLICKED, /* The effect has ended with a click */
349         LONG_CLICKED, /* The effect has ended with a long-click */
350     }
351 
352     /** Callbacks to notify view and animator actions */
353     interface Callback {
354 
355         /** Reset the tile visual properties */
onResetPropertiesnull356         fun onResetProperties()
357 
358         /** Event where the effect completed by being reversed */
359         fun onEffectFinishedReversing()
360 
361         /** Start the effect animator */
362         fun onStartAnimator()
363 
364         /** Reverse the effect animator */
365         fun onReverseAnimator(playHaptics: Boolean = true)
366 
367         /** Cancel the effect animator */
368         fun onCancelAnimator()
369     }
370 
371     companion object {
372         private const val TAG = "QSLongPressEffect"
373         private const val DEBUG = true
374     }
375 }
376