blob: 6fa06a76dc5edb0cb26e5794f92fcad69427058b [file] [log] [blame]
package com.android.systemui.statusbar
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.Configuration
import android.os.SystemClock
import android.util.DisplayMetrics
import android.util.MathUtils
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.annotation.VisibleForTesting
import com.android.internal.logging.nano.MetricsProto.MetricsEvent
import com.android.systemui.ExpandHelper
import com.android.systemui.Gefingerpoken
import com.android.systemui.R
import com.android.systemui.animation.Interpolators
import com.android.systemui.biometrics.UdfpsKeyguardViewController
import com.android.systemui.classifier.Classifier
import com.android.systemui.classifier.FalsingCollector
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.media.MediaHierarchyManager
import com.android.systemui.plugins.ActivityStarter.OnDismissAction
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.plugins.qs.QS
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
import com.android.systemui.statusbar.notification.row.ExpandableView
import com.android.systemui.statusbar.notification.stack.AmbientState
import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
import com.android.systemui.statusbar.phone.KeyguardBypassController
import com.android.systemui.statusbar.phone.LockscreenGestureLogger
import com.android.systemui.statusbar.phone.LockscreenGestureLogger.LockscreenUiEvent
import com.android.systemui.statusbar.phone.NotificationPanelViewController
import com.android.systemui.statusbar.phone.ScrimController
import com.android.systemui.statusbar.phone.StatusBar
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.Utils
import javax.inject.Inject
private const val SPRING_BACK_ANIMATION_LENGTH_MS = 375L
private const val RUBBERBAND_FACTOR_STATIC = 0.15f
private const val RUBBERBAND_FACTOR_EXPANDABLE = 0.5f
/**
* A class that controls the lockscreen to shade transition
*/
@SysUISingleton
class LockscreenShadeTransitionController @Inject constructor(
private val statusBarStateController: SysuiStatusBarStateController,
private val lockscreenGestureLogger: LockscreenGestureLogger,
private val keyguardBypassController: KeyguardBypassController,
private val lockScreenUserManager: NotificationLockscreenUserManager,
private val falsingCollector: FalsingCollector,
private val ambientState: AmbientState,
private val displayMetrics: DisplayMetrics,
private val mediaHierarchyManager: MediaHierarchyManager,
private val scrimController: ScrimController,
private val depthController: NotificationShadeDepthController,
private val featureFlags: FeatureFlags,
private val context: Context,
configurationController: ConfigurationController,
falsingManager: FalsingManager
) {
private var pulseHeight: Float = 0f
private var useSplitShade: Boolean = false
private lateinit var nsslController: NotificationStackScrollLayoutController
lateinit var notificationPanelController: NotificationPanelViewController
lateinit var statusbar: StatusBar
lateinit var qS: QS
/**
* A handler that handles the next keyguard dismiss animation.
*/
private var animationHandlerOnKeyguardDismiss: ((Long) -> Unit)? = null
/**
* The entry that was just dragged down on.
*/
private var draggedDownEntry: NotificationEntry? = null
/**
* The current animator if any
*/
@VisibleForTesting
internal var dragDownAnimator: ValueAnimator? = null
/**
* The current pulse height animator if any
*/
@VisibleForTesting
internal var pulseHeightAnimator: ValueAnimator? = null
/**
* Distance that the full shade transition takes in order for scrim to fully transition to
* the shade (in alpha)
*/
private var scrimTransitionDistance = 0
/**
* Distance that the full transition takes in order for us to fully transition to the shade
*/
private var fullTransitionDistance = 0
/**
* Flag to make sure that the dragDownAmount is applied to the listeners even when in the
* locked down shade.
*/
private var forceApplyAmount = false
/**
* A flag to suppress the default animation when unlocking in the locked down shade.
*/
private var nextHideKeyguardNeedsNoAnimation = false
/**
* The distance until we're showing the notifications when pulsing
*/
val distanceUntilShowingPulsingNotifications
get() = scrimTransitionDistance
/**
* The udfpsKeyguardViewController if it exists.
*/
var udfpsKeyguardViewController: UdfpsKeyguardViewController? = null
/**
* The touch helper responsible for the drag down animation.
*/
val touchHelper = DragDownHelper(falsingManager, falsingCollector, this, context)
init {
updateResources()
configurationController.addCallback(object : ConfigurationController.ConfigurationListener {
override fun onConfigChanged(newConfig: Configuration?) {
updateResources()
touchHelper.updateResources(context)
}
})
}
private fun updateResources() {
scrimTransitionDistance = context.resources.getDimensionPixelSize(
R.dimen.lockscreen_shade_scrim_transition_distance)
fullTransitionDistance = context.resources.getDimensionPixelSize(
R.dimen.lockscreen_shade_qs_transition_distance)
useSplitShade = Utils.shouldUseSplitNotificationShade(featureFlags, context.resources)
}
fun setStackScroller(nsslController: NotificationStackScrollLayoutController) {
this.nsslController = nsslController
touchHelper.host = nsslController.view
touchHelper.expandCallback = nsslController.expandHelperCallback
}
/**
* Initialize the shelf controller such that clicks on it will expand the shade
*/
fun bindController(notificationShelfController: NotificationShelfController) {
// Bind the click listener of the shelf to go to the full shade
notificationShelfController.setOnClickListener {
if (statusBarStateController.state == StatusBarState.KEYGUARD) {
statusbar.wakeUpIfDozing(SystemClock.uptimeMillis(), it, "SHADE_CLICK")
goToLockedShade(it)
}
}
}
/**
* @return true if the interaction is accepted, false if it should be cancelled
*/
internal fun canDragDown(): Boolean {
return (statusBarStateController.state == StatusBarState.KEYGUARD ||
nsslController.isInLockedDownShade()) &&
qS.isFullyCollapsed
}
/**
* Called by the touch helper when when a gesture has completed all the way and released.
*/
internal fun onDraggedDown(startingChild: View?, dragLengthY: Int) {
if (canDragDown()) {
if (nsslController.isInLockedDownShade()) {
statusBarStateController.setLeaveOpenOnKeyguardHide(true)
statusbar.dismissKeyguardThenExecute(OnDismissAction {
nextHideKeyguardNeedsNoAnimation = true
false
},
null /* cancelRunnable */, false /* afterKeyguardGone */)
} else {
lockscreenGestureLogger.write(
MetricsEvent.ACTION_LS_SHADE,
(dragLengthY / displayMetrics.density).toInt(),
0 /* velocityDp */)
lockscreenGestureLogger.log(LockscreenUiEvent.LOCKSCREEN_PULL_SHADE_OPEN)
if (!ambientState.isDozing() || startingChild != null) {
// go to locked shade while animating the drag down amount from its current
// value
val animationHandler = { delay: Long ->
if (startingChild is ExpandableNotificationRow) {
startingChild.onExpandedByGesture(
true /* drag down is always an open */)
}
notificationPanelController.animateToFullShade(delay)
notificationPanelController.setTransitionToFullShadeAmount(0f,
true /* animated */, delay)
// Let's reset ourselves, ready for the next animation
// changing to shade locked will make isInLockDownShade true, so let's
// override that
forceApplyAmount = true
// Reset the behavior. At this point the animation is already started
dragDownAmount = 0f
forceApplyAmount = false
}
val cancelRunnable = Runnable { setDragDownAmountAnimated(0f) }
goToLockedShadeInternal(startingChild, animationHandler, cancelRunnable)
}
}
} else {
setDragDownAmountAnimated(0f)
}
}
/**
* Called by the touch helper when the drag down was aborted and should be reset.
*/
internal fun onDragDownReset() {
nsslController.setDimmed(true /* dimmed */, true /* animated */)
nsslController.resetScrollPosition()
nsslController.resetCheckSnoozeLeavebehind()
setDragDownAmountAnimated(0f)
}
/**
* The user has dragged either above or below the threshold which changes the dimmed state.
* @param above whether they dragged above it
*/
internal fun onCrossedThreshold(above: Boolean) {
nsslController.setDimmed(!above /* dimmed */, true /* animate */)
}
/**
* Called by the touch helper when the drag down was started
*/
internal fun onDragDownStarted() {
nsslController.cancelLongPress()
nsslController.checkSnoozeLeavebehind()
dragDownAnimator?.cancel()
}
/**
* Do we need a falsing check currently?
*/
internal val isFalsingCheckNeeded: Boolean
get() = statusBarStateController.state == StatusBarState.KEYGUARD
/**
* Is dragging down enabled on a given view
* @param view The view to check or `null` to check if it's enabled at all
*/
internal fun isDragDownEnabledForView(view: ExpandableView?): Boolean {
if (isDragDownAnywhereEnabled) {
return true
}
if (nsslController.isInLockedDownShade()) {
if (view == null) {
// Dragging down is allowed in general
return true
}
if (view is ExpandableNotificationRow) {
// Only drag down on sensitive views, otherwise the ExpandHelper will take this
return view.entry.isSensitive
}
}
return false
}
/**
* @return if drag down is enabled anywhere, not just on selected views.
*/
internal val isDragDownAnywhereEnabled: Boolean
get() = (statusBarStateController.getState() == StatusBarState.KEYGUARD &&
!keyguardBypassController.bypassEnabled &&
qS.isFullyCollapsed)
/**
* The amount in pixels that the user has dragged down.
*/
internal var dragDownAmount = 0f
set(value) {
if (field != value || forceApplyAmount) {
field = value
if (!nsslController.isInLockedDownShade() || forceApplyAmount) {
nsslController.setTransitionToFullShadeAmount(field)
notificationPanelController.setTransitionToFullShadeAmount(field,
false /* animate */, 0 /* delay */)
// TODO: appear qs also in split shade
val qsAmount = if (useSplitShade) 0f else field
qS.setTransitionToFullShadeAmount(qsAmount, false /* animate */)
// TODO: appear media also in split shade
val mediaAmount = if (useSplitShade) 0f else field
mediaHierarchyManager.setTransitionToFullShadeAmount(mediaAmount)
transitionToShadeAmountCommon(field)
}
}
}
private fun transitionToShadeAmountCommon(dragDownAmount: Float) {
val scrimProgress = MathUtils.saturate(dragDownAmount / scrimTransitionDistance)
scrimController.setTransitionToFullShadeProgress(scrimProgress)
// Fade out all content only visible on the lockscreen
notificationPanelController.setKeyguardOnlyContentAlpha(1.0f - scrimProgress)
depthController.transitionToFullShadeProgress = scrimProgress
udfpsKeyguardViewController?.setTransitionToFullShadeProgress(scrimProgress)
}
private fun setDragDownAmountAnimated(
target: Float,
delay: Long = 0,
endlistener: (() -> Unit)? = null
) {
val dragDownAnimator = ValueAnimator.ofFloat(dragDownAmount, target)
dragDownAnimator.interpolator = Interpolators.FAST_OUT_SLOW_IN
dragDownAnimator.duration = SPRING_BACK_ANIMATION_LENGTH_MS
dragDownAnimator.addUpdateListener { animation: ValueAnimator ->
dragDownAmount = animation.animatedValue as Float
}
if (delay > 0) {
dragDownAnimator.startDelay = delay
}
if (endlistener != null) {
dragDownAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
endlistener.invoke()
}
})
}
dragDownAnimator.start()
this.dragDownAnimator = dragDownAnimator
}
/**
* Animate appear the drag down amount.
*/
private fun animateAppear(delay: Long = 0) {
// changing to shade locked will make isInLockDownShade true, so let's override
// that
forceApplyAmount = true
// we set the value initially to 1 pixel, since that will make sure we're
// transitioning to the full shade. this is important to avoid flickering,
// as the below animation only starts once the shade is unlocked, which can
// be a couple of frames later. if we're setting it to 0, it will use the
// default inset and therefore flicker
dragDownAmount = 1f
setDragDownAmountAnimated(fullTransitionDistance.toFloat(), delay = delay) {
// End listener:
// Reset
dragDownAmount = 0f
forceApplyAmount = false
}
}
/**
* Ask this controller to go to the locked shade, changing the state change and doing
* an animation, where the qs appears from 0 from the top
*
* If secure with redaction: Show bouncer, go to unlocked shade.
* If secure without redaction or no security: Go to [StatusBarState.SHADE_LOCKED].
*
* @param expandView The view to expand after going to the shade
* @param needsQSAnimation if this needs the quick settings to slide in from the top or if
* that's already handled separately
*/
@JvmOverloads
fun goToLockedShade(expandedView: View?, needsQSAnimation: Boolean = true) {
if (statusBarStateController.state == StatusBarState.KEYGUARD) {
val animationHandler: ((Long) -> Unit)?
if (needsQSAnimation) {
// Let's use the default animation
animationHandler = null
} else {
// Let's only animate notifications
animationHandler = { delay: Long ->
notificationPanelController.animateToFullShade(delay)
}
}
goToLockedShadeInternal(expandedView, animationHandler,
cancelAction = null)
}
}
/**
* If secure with redaction: Show bouncer, go to unlocked shade.
*
* If secure without redaction or no security: Go to [StatusBarState.SHADE_LOCKED].
*
* @param expandView The view to expand after going to the shade.
* @param animationHandler The handler which performs the go to full shade animation. If null,
* the default handler will do the animation, otherwise the caller is
* responsible for the animation. The input value is a Long for the
* delay for the animation.
* @param cancelAction The runnable to invoke when the transition is aborted. This happens if
* the user goes to the bouncer and goes back.
*/
private fun goToLockedShadeInternal(
expandView: View?,
animationHandler: ((Long) -> Unit)? = null,
cancelAction: Runnable? = null
) {
if (statusbar.isShadeDisabled) {
cancelAction?.run()
return
}
var userId: Int = lockScreenUserManager.getCurrentUserId()
var entry: NotificationEntry? = null
if (expandView is ExpandableNotificationRow) {
entry = expandView.entry
entry.setUserExpanded(true /* userExpanded */, true /* allowChildExpansion */)
// Indicate that the group expansion is changing at this time -- this way the group
// and children backgrounds / divider animations will look correct.
entry.setGroupExpansionChanging(true)
userId = entry.sbn.userId
}
var fullShadeNeedsBouncer = (!lockScreenUserManager.userAllowsPrivateNotificationsInPublic(
lockScreenUserManager.getCurrentUserId()) ||
!lockScreenUserManager.shouldShowLockscreenNotifications() ||
falsingCollector.shouldEnforceBouncer())
if (keyguardBypassController.bypassEnabled) {
fullShadeNeedsBouncer = false
}
if (lockScreenUserManager.isLockscreenPublicMode(userId) && fullShadeNeedsBouncer) {
statusBarStateController.setLeaveOpenOnKeyguardHide(true)
var onDismissAction: OnDismissAction? = null
if (animationHandler != null) {
onDismissAction = OnDismissAction {
// We're waiting on keyguard to hide before triggering the action,
// as that will make the animation work properly
animationHandlerOnKeyguardDismiss = animationHandler
false
}
}
val cancelHandler = Runnable {
draggedDownEntry?.apply {
setUserLocked(false)
notifyHeightChanged(false /* needsAnimation */)
draggedDownEntry = null
}
cancelAction?.run()
}
statusbar.showBouncerWithDimissAndCancelIfKeyguard(onDismissAction, cancelHandler)
draggedDownEntry = entry
} else {
statusBarStateController.setState(StatusBarState.SHADE_LOCKED)
// This call needs to be after updating the shade state since otherwise
// the scrimstate resets too early
if (animationHandler != null) {
animationHandler.invoke(0 /* delay */)
} else {
performDefaultGoToFullShadeAnimation(0)
}
}
}
/**
* Notify this handler that the keyguard was just dismissed and that a animation to
* the full shade should happen.
*
* @param delay the delay to do the animation with
* @param previousState which state were we in when we hid the keyguard?
*/
fun onHideKeyguard(delay: Long, previousState: Int) {
if (animationHandlerOnKeyguardDismiss != null) {
animationHandlerOnKeyguardDismiss!!.invoke(delay)
animationHandlerOnKeyguardDismiss = null
} else {
if (nextHideKeyguardNeedsNoAnimation) {
nextHideKeyguardNeedsNoAnimation = false
} else if (previousState != StatusBarState.SHADE_LOCKED) {
// No animation necessary if we already were in the shade locked!
performDefaultGoToFullShadeAnimation(delay)
}
}
draggedDownEntry?.apply {
setUserLocked(false)
draggedDownEntry = null
}
}
/**
* Perform the default appear animation when going to the full shade. This is called when
* not triggered by gestures, e.g. when clicking on the shelf or expand button.
*/
private fun performDefaultGoToFullShadeAnimation(delay: Long) {
notificationPanelController.animateToFullShade(delay)
animateAppear(delay)
}
//
// PULSE EXPANSION
//
/**
* Set the height how tall notifications are pulsing. This is only set whenever we are expanding
* from a pulse and determines how much the notifications are expanded.
*/
fun setPulseHeight(height: Float, animate: Boolean = false) {
if (animate) {
val pulseHeightAnimator = ValueAnimator.ofFloat(pulseHeight, height)
pulseHeightAnimator.interpolator = Interpolators.FAST_OUT_SLOW_IN
pulseHeightAnimator.duration = SPRING_BACK_ANIMATION_LENGTH_MS
pulseHeightAnimator.addUpdateListener { animation: ValueAnimator ->
setPulseHeight(animation.animatedValue as Float)
}
pulseHeightAnimator.start()
this.pulseHeightAnimator = pulseHeightAnimator
} else {
pulseHeight = height
val overflow = nsslController.setPulseHeight(height)
notificationPanelController.setOverStrechAmount(overflow)
val transitionHeight = if (keyguardBypassController.bypassEnabled) height else 0.0f
transitionToShadeAmountCommon(transitionHeight)
}
}
/**
* Finish the pulse animation when the touch interaction finishes
* @param cancelled was the interaction cancelled and this is a reset?
*/
fun finishPulseAnimation(cancelled: Boolean) {
if (cancelled) {
setPulseHeight(0f, animate = true)
} else {
notificationPanelController.onPulseExpansionFinished()
setPulseHeight(0f, animate = false)
}
}
/**
* Notify this class that a pulse expansion is starting
*/
fun onPulseExpansionStarted() {
pulseHeightAnimator?.cancel()
}
}
/**
* A utility class to enable the downward swipe on the lockscreen to go to the full shade and expand
* the notification where the drag started.
*/
class DragDownHelper(
private val falsingManager: FalsingManager,
private val falsingCollector: FalsingCollector,
private val dragDownCallback: LockscreenShadeTransitionController,
context: Context
) : Gefingerpoken {
private var dragDownAmountOnStart = 0.0f
lateinit var expandCallback: ExpandHelper.Callback
lateinit var host: View
private var minDragDistance = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
private var touchSlop = 0f
private var slopMultiplier = 0f
private val temp2 = IntArray(2)
private var draggedFarEnough = false
private var startingChild: ExpandableView? = null
private var lastHeight = 0f
var isDraggingDown = false
private set
private val isFalseTouch: Boolean
get() {
return if (!dragDownCallback.isFalsingCheckNeeded) {
false
} else {
falsingManager.isFalseTouch(Classifier.NOTIFICATION_DRAG_DOWN) || !draggedFarEnough
}
}
val isDragDownEnabled: Boolean
get() = dragDownCallback.isDragDownEnabledForView(null)
init {
updateResources(context)
}
fun updateResources(context: Context) {
minDragDistance = context.resources.getDimensionPixelSize(
R.dimen.keyguard_drag_down_min_distance)
val configuration = ViewConfiguration.get(context)
touchSlop = configuration.scaledTouchSlop.toFloat()
slopMultiplier = configuration.scaledAmbiguousGestureMultiplier
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
draggedFarEnough = false
isDraggingDown = false
startingChild = null
initialTouchY = y
initialTouchX = x
}
MotionEvent.ACTION_MOVE -> {
val h = y - initialTouchY
// Adjust the touch slop if another gesture may be being performed.
val touchSlop = if (event.classification
== MotionEvent.CLASSIFICATION_AMBIGUOUS_GESTURE)
touchSlop * slopMultiplier
else
touchSlop
if (h > touchSlop && h > Math.abs(x - initialTouchX)) {
falsingCollector.onNotificationStartDraggingDown()
isDraggingDown = true
captureStartingChild(initialTouchX, initialTouchY)
initialTouchY = y
initialTouchX = x
dragDownCallback.onDragDownStarted()
dragDownAmountOnStart = dragDownCallback.dragDownAmount
return startingChild != null || dragDownCallback.isDragDownAnywhereEnabled
}
}
}
return false
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isDraggingDown) {
return false
}
val x = event.x
val y = event.y
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
lastHeight = y - initialTouchY
captureStartingChild(initialTouchX, initialTouchY)
dragDownCallback.dragDownAmount = lastHeight + dragDownAmountOnStart
if (startingChild != null) {
handleExpansion(lastHeight, startingChild!!)
}
if (lastHeight > minDragDistance) {
if (!draggedFarEnough) {
draggedFarEnough = true
dragDownCallback.onCrossedThreshold(true)
}
} else {
if (draggedFarEnough) {
draggedFarEnough = false
dragDownCallback.onCrossedThreshold(false)
}
}
return true
}
MotionEvent.ACTION_UP -> if (!falsingManager.isUnlockingDisabled && !isFalseTouch &&
dragDownCallback.canDragDown()) {
dragDownCallback.onDraggedDown(startingChild, (y - initialTouchY).toInt())
if (startingChild != null) {
expandCallback.setUserLockedChild(startingChild, false)
startingChild = null
}
isDraggingDown = false
} else {
stopDragging()
return false
}
MotionEvent.ACTION_CANCEL -> {
stopDragging()
return false
}
}
return false
}
private fun captureStartingChild(x: Float, y: Float) {
if (startingChild == null) {
startingChild = findView(x, y)
if (startingChild != null) {
if (dragDownCallback.isDragDownEnabledForView(startingChild)) {
expandCallback.setUserLockedChild(startingChild, true)
} else {
startingChild = null
}
}
}
}
private fun handleExpansion(heightDelta: Float, child: ExpandableView) {
var hDelta = heightDelta
if (hDelta < 0) {
hDelta = 0f
}
val expandable = child.isContentExpandable
val rubberbandFactor = if (expandable) {
RUBBERBAND_FACTOR_EXPANDABLE
} else {
RUBBERBAND_FACTOR_STATIC
}
var rubberband = hDelta * rubberbandFactor
if (expandable && rubberband + child.collapsedHeight > child.maxContentHeight) {
var overshoot = rubberband + child.collapsedHeight - child.maxContentHeight
overshoot *= 1 - RUBBERBAND_FACTOR_STATIC
rubberband -= overshoot
}
child.actualHeight = (child.collapsedHeight + rubberband).toInt()
}
private fun cancelChildExpansion(child: ExpandableView) {
if (child.actualHeight == child.collapsedHeight) {
expandCallback.setUserLockedChild(child, false)
return
}
val anim = ObjectAnimator.ofInt(child, "actualHeight",
child.actualHeight, child.collapsedHeight)
anim.interpolator = Interpolators.FAST_OUT_SLOW_IN
anim.duration = SPRING_BACK_ANIMATION_LENGTH_MS
anim.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
expandCallback.setUserLockedChild(child, false)
}
})
anim.start()
}
private fun stopDragging() {
falsingCollector.onNotificationStopDraggingDown()
if (startingChild != null) {
cancelChildExpansion(startingChild!!)
startingChild = null
}
isDraggingDown = false
dragDownCallback.onDragDownReset()
}
private fun findView(x: Float, y: Float): ExpandableView? {
host.getLocationOnScreen(temp2)
return expandCallback.getChildAtRawPosition(x + temp2[0], y + temp2[1])
}
}