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