diff --git a/packages/SystemUI/res/drawable/statusbar_chip_bg.xml b/packages/SystemUI/res/drawable/statusbar_chip_bg.xml new file mode 100644 index 0000000000000..d7de16d7c5bb7 --- /dev/null +++ b/packages/SystemUI/res/drawable/statusbar_chip_bg.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/drawable/privacy_chip_bg.xml b/packages/SystemUI/res/drawable/statusbar_privacy_chip_bg.xml similarity index 100% rename from packages/SystemUI/res/drawable/privacy_chip_bg.xml rename to packages/SystemUI/res/drawable/statusbar_privacy_chip_bg.xml diff --git a/packages/SystemUI/res/layout/battery_status_chip.xml b/packages/SystemUI/res/layout/battery_status_chip.xml new file mode 100644 index 0000000000000..ff68ac0f9a71e --- /dev/null +++ b/packages/SystemUI/res/layout/battery_status_chip.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/combined_qs_header.xml b/packages/SystemUI/res/layout/combined_qs_header.xml index d689828764489..dffe40b8454ab 100644 --- a/packages/SystemUI/res/layout/combined_qs_header.xml +++ b/packages/SystemUI/res/layout/combined_qs_header.xml @@ -141,11 +141,14 @@ android:layout_width="wrap_content" android:layout_height="@dimen/large_screen_shade_header_min_height" android:gravity="center" - app:layout_constraintEnd_toEndOf="@id/end_guide" - app:layout_constraintTop_toTopOf="@id/date" app:layout_constraintBottom_toBottomOf="@id/date" - > - + app:layout_constraintEnd_toEndOf="@id/end_guide" + app:layout_constraintTop_toTopOf="@id/date"> + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/ongoing_privacy_chip.xml b/packages/SystemUI/res/layout/ongoing_privacy_chip.xml index d1a2cf4c24b21..2c7467d726b42 100644 --- a/packages/SystemUI/res/layout/ongoing_privacy_chip.xml +++ b/packages/SystemUI/res/layout/ongoing_privacy_chip.xml @@ -16,16 +16,15 @@ --> - > - \ No newline at end of file + android:maxWidth="@dimen/ongoing_appops_chip_max_width" /> + \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/battery/BatteryMeterView.java b/packages/SystemUI/src/com/android/systemui/battery/BatteryMeterView.java index 03d999f697d07..ce4253477a41a 100644 --- a/packages/SystemUI/src/com/android/systemui/battery/BatteryMeterView.java +++ b/packages/SystemUI/src/com/android/systemui/battery/BatteryMeterView.java @@ -24,6 +24,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.animation.LayoutTransition; import android.animation.ObjectAnimator; import android.annotation.IntDef; +import android.annotation.IntRange; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; @@ -195,7 +196,13 @@ public class BatteryMeterView extends LinearLayout implements DarkReceiver { return false; } - void onBatteryLevelChanged(int level, boolean pluggedIn) { + /** + * Update battery level + * + * @param level int between 0 and 100 (representing percentage value) + * @param pluggedIn whether the device is plugged in or not + */ + public void onBatteryLevelChanged(@IntRange(from = 0, to = 100) int level, boolean pluggedIn) { mDrawable.setCharging(pluggedIn); mDrawable.setBatteryLevel(level); mCharging = pluggedIn; diff --git a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java index 03a1dc068d3d5..378b7bb618f82 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java @@ -52,6 +52,7 @@ import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.NotificationLockscreenUserManagerImpl; import com.android.systemui.statusbar.NotificationShadeWindowController; import com.android.systemui.statusbar.dagger.StartCentralSurfacesModule; +import com.android.systemui.statusbar.events.StatusBarEventsModule; import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider; import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager; import com.android.systemui.statusbar.phone.DozeServiceHost; @@ -101,6 +102,7 @@ import dagger.Provides; QSModule.class, ReferenceScreenshotModule.class, RotationLockModule.class, + StatusBarEventsModule.class, StartCentralSurfacesModule.class, VolumeModule.class }) diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 5b4ce065791d3..f0ee44305b10f 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -255,6 +255,11 @@ public abstract class SystemUIModule { @BindsOptionalOf abstract FingerprintInteractiveToAuthProvider optionalFingerprintInteractiveToAuthProvider(); + @BindsOptionalOf + //TODO(b/269430792 remove full qualifier. Full qualifier is used to avoid merge conflict.) + abstract com.android.systemui.statusbar.events.SystemStatusAnimationScheduler + optionalSystemStatusAnimationScheduler(); + @SysUISingleton @Binds abstract SystemClock bindSystemClock(SystemClockImpl systemClock); diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt index 93c857fba76d2..52debfeed77f1 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt @@ -298,6 +298,9 @@ object Flags { val NEW_STATUS_BAR_ICONS_DEBUG_COLORING = unreleasedFlag(611, "new_status_bar_icons_debug_coloring") + // TODO(b/265892345): Tracking Bug + val PLUG_IN_STATUS_BAR_CHIP = unreleasedFlag(265892345, "plug_in_status_bar_chip") + // 700 - dialer/calls // TODO(b/254512734): Tracking Bug val ONGOING_CALL_STATUS_BAR_CHIP = releasedFlag(700, "ongoing_call_status_bar_chip") diff --git a/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt b/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt index 8ad2f867a073b..79167f276576d 100644 --- a/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt +++ b/packages/SystemUI/src/com/android/systemui/privacy/OngoingPrivacyChip.kt @@ -16,7 +16,11 @@ package com.android.systemui.privacy import android.content.Context import android.util.AttributeSet +import android.view.Gravity.CENTER_VERTICAL +import android.view.Gravity.END import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.ImageView import android.widget.LinearLayout import com.android.settingslib.Utils @@ -35,7 +39,7 @@ class OngoingPrivacyChip @JvmOverloads constructor( private var iconSize = 0 private var iconColor = 0 - private lateinit var iconsContainer: LinearLayout + private val iconsContainer: LinearLayout var privacyList = emptyList() set(value) { @@ -43,11 +47,13 @@ class OngoingPrivacyChip @JvmOverloads constructor( updateView(PrivacyChipBuilder(context, field)) } - override fun onFinishInflate() { - super.onFinishInflate() - + init { + inflate(context, R.layout.ongoing_privacy_chip, this) + id = R.id.privacy_chip + layoutParams = LayoutParams(WRAP_CONTENT, MATCH_PARENT, CENTER_VERTICAL or END) + clipChildren = true + clipToPadding = true iconsContainer = requireViewById(R.id.icons_container) - updateResources() } @@ -107,6 +113,6 @@ class OngoingPrivacyChip @JvmOverloads constructor( val padding = context.resources .getDimensionPixelSize(R.dimen.ongoing_appops_chip_side_padding) iconsContainer.setPaddingRelative(padding, 0, padding, 0) - iconsContainer.background = context.getDrawable(R.drawable.privacy_chip_bg) + iconsContainer.background = context.getDrawable(R.drawable.statusbar_privacy_chip_bg) } } \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/BatteryStatusChip.kt b/packages/SystemUI/src/com/android/systemui/statusbar/BatteryStatusChip.kt new file mode 100644 index 0000000000000..37140ec2aa323 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/BatteryStatusChip.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 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.statusbar + +import android.annotation.IntRange +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import com.android.settingslib.Utils +import com.android.systemui.R +import com.android.systemui.battery.BatteryMeterView +import com.android.systemui.statusbar.events.BackgroundAnimatableView + +class BatteryStatusChip @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + FrameLayout(context, attrs), BackgroundAnimatableView { + + private val roundedContainer: LinearLayout + private val batteryMeterView: BatteryMeterView + override val contentView: View + get() = batteryMeterView + + init { + inflate(context, R.layout.battery_status_chip, this) + roundedContainer = findViewById(R.id.rounded_container) + batteryMeterView = findViewById(R.id.battery_meter_view) + updateResources() + } + + /** + * When animating as a chip in the status bar, we want to animate the width for the rounded + * container. We have to subtract our own top and left offset because the bounds come to us as + * absolute on-screen bounds. + */ + override fun setBoundsForAnimation(l: Int, t: Int, r: Int, b: Int) { + roundedContainer.setLeftTopRightBottom(l - left, t - top, r - left, b - top) + } + + fun setBatteryLevel(@IntRange(from = 0, to = 100) batteryLevel: Int) { + batteryMeterView.setForceShowPercent(true) + batteryMeterView.onBatteryLevelChanged(batteryLevel, true) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + updateResources() + } + + @SuppressLint("UseCompatLoadingForDrawables") + private fun updateResources() { + val primaryColor = + Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.colorPrimary) + val textColorSecondary = + Utils.getColorAttrDefaultColor(mContext, android.R.attr.textColorSecondary) + batteryMeterView.updateColors(primaryColor, textColorSecondary, primaryColor) + roundedContainer.background = mContext.getDrawable(R.drawable.statusbar_chip_bg) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/PrivacyDotViewController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/PrivacyDotViewController.kt index f25928418cbdb..6a8d5c1fca9a4 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/PrivacyDotViewController.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/PrivacyDotViewController.kt @@ -16,7 +16,7 @@ package com.android.systemui.statusbar.events -import android.animation.Animator +import androidx.core.animation.Animator import android.annotation.UiThread import android.graphics.Point import android.graphics.Rect diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusBarEventsModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusBarEventsModule.kt new file mode 100644 index 0000000000000..3d6d48917dd32 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusBarEventsModule.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2023 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.statusbar.events + +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.dump.DumpManager +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.statusbar.window.StatusBarWindowController +import com.android.systemui.util.concurrency.DelayableExecutor +import com.android.systemui.util.time.SystemClock +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineScope + +@Module +interface StatusBarEventsModule { + + companion object { + + @Provides + @SysUISingleton + fun provideSystemStatusAnimationScheduler( + featureFlags: FeatureFlags, + coordinator: SystemEventCoordinator, + chipAnimationController: SystemEventChipAnimationController, + statusBarWindowController: StatusBarWindowController, + dumpManager: DumpManager, + systemClock: SystemClock, + @Application coroutineScope: CoroutineScope, + @Main executor: DelayableExecutor + ): SystemStatusAnimationScheduler { + return if (featureFlags.isEnabled(Flags.PLUG_IN_STATUS_BAR_CHIP)) { + SystemStatusAnimationSchedulerImpl( + coordinator, + chipAnimationController, + statusBarWindowController, + dumpManager, + systemClock, + coroutineScope + ) + } else { + SystemStatusAnimationSchedulerLegacyImpl( + coordinator, + chipAnimationController, + statusBarWindowController, + dumpManager, + systemClock, + executor + ) + } + } + } +} + diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt index 4e1404d0637b0..43f78c3166e48 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt @@ -16,24 +16,21 @@ package com.android.systemui.statusbar.events +import android.annotation.IntRange import android.annotation.SuppressLint import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.LayoutInflater import android.view.View import android.widget.ImageView -import com.android.settingslib.graph.ThemedBatteryDrawable -import com.android.systemui.R import com.android.systemui.privacy.OngoingPrivacyChip import com.android.systemui.privacy.PrivacyItem +import com.android.systemui.statusbar.BatteryStatusChip typealias ViewCreator = (context: Context) -> BackgroundAnimatableView interface StatusEvent { val priority: Int // Whether or not to force the status bar open and show a dot - val forceVisible: Boolean + var forceVisible: Boolean // Whether or not to show an animation for this event val showAnimation: Boolean val viewCreator: ViewCreator @@ -73,17 +70,16 @@ class BGImageView( } } -class BatteryEvent : StatusEvent { +class BatteryEvent(@IntRange(from = 0, to = 100) val batteryLevel: Int) : StatusEvent { override val priority = 50 - override val forceVisible = false + override var forceVisible = false override val showAnimation = true override var contentDescription: String? = "" - override val viewCreator: (context: Context) -> BGImageView = { context -> - val iv = BGImageView(context) - iv.setImageDrawable(ThemedBatteryDrawable(context, Color.WHITE)) - iv.setBackgroundDrawable(ColorDrawable(Color.GREEN)) - iv + override val viewCreator: ViewCreator = { context -> + BatteryStatusChip(context).apply { + setBatteryLevel(batteryLevel) + } } override fun toString(): String { @@ -94,13 +90,12 @@ class BatteryEvent : StatusEvent { class PrivacyEvent(override val showAnimation: Boolean = true) : StatusEvent { override var contentDescription: String? = null override val priority = 100 - override val forceVisible = true + override var forceVisible = true var privacyItems: List = listOf() private var privacyChip: OngoingPrivacyChip? = null override val viewCreator: ViewCreator = { context -> - val v = LayoutInflater.from(context) - .inflate(R.layout.ongoing_privacy_chip, null) as OngoingPrivacyChip + val v = OngoingPrivacyChip(context) v.privacyList = privacyItems v.contentDescription = contentDescription privacyChip = v diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt index 8405aea218f01..776956a20140a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt @@ -16,10 +16,6 @@ package com.android.systemui.statusbar.events -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ValueAnimator import android.content.Context import android.graphics.Rect import android.view.ContextThemeWrapper @@ -30,7 +26,13 @@ import android.view.View.MeasureSpec.AT_MOST import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout +import androidx.core.animation.Animator +import androidx.core.animation.AnimatorListenerAdapter +import androidx.core.animation.AnimatorSet +import androidx.core.animation.ValueAnimator import com.android.systemui.R +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider import com.android.systemui.statusbar.window.StatusBarWindowController import com.android.systemui.util.animation.AnimationUtil.Companion.frames @@ -43,7 +45,8 @@ import kotlin.math.roundToInt class SystemEventChipAnimationController @Inject constructor( private val context: Context, private val statusBarWindowController: StatusBarWindowController, - private val contentInsetsProvider: StatusBarContentInsetsProvider + private val contentInsetsProvider: StatusBarContentInsetsProvider, + private val featureFlags: FeatureFlags ) : SystemStatusAnimationCallback { private lateinit var animationWindowView: FrameLayout @@ -53,12 +56,14 @@ class SystemEventChipAnimationController @Inject constructor( // Left for LTR, Right for RTL private var animationDirection = LEFT - private var chipRight = 0 - private var chipLeft = 0 - private var chipWidth = 0 + private var chipBounds = Rect() + private val chipWidth get() = chipBounds.width() + private val chipRight get() = chipBounds.right + private val chipLeft get() = chipBounds.left private var chipMinWidth = context.resources.getDimensionPixelSize( R.dimen.ongoing_appops_chip_min_animation_width) - private var dotSize = context.resources.getDimensionPixelSize( + + private val dotSize = context.resources.getDimensionPixelSize( R.dimen.ongoing_appops_dot_diameter) // Use during animation so that multiple animators can update the drawing rect private var animRect = Rect() @@ -90,21 +95,26 @@ class SystemEventChipAnimationController @Inject constructor( it.view.measure( View.MeasureSpec.makeMeasureSpec( (animationWindowView.parent as View).width, AT_MOST), - View.MeasureSpec.makeMeasureSpec(animationWindowView.height, AT_MOST)) - chipWidth = it.chipWidth - } + View.MeasureSpec.makeMeasureSpec( + (animationWindowView.parent as View).height, AT_MOST)) - // decide which direction we're animating from, and then set some screen coordinates - val contentRect = contentInsetsProvider.getStatusBarContentAreaForCurrentRotation() - when (animationDirection) { - LEFT -> { - chipRight = contentRect.right - chipLeft = contentRect.right - chipWidth - } - else /* RIGHT */ -> { - chipLeft = contentRect.left - chipRight = contentRect.left + chipWidth + // decide which direction we're animating from, and then set some screen coordinates + val contentRect = contentInsetsProvider.getStatusBarContentAreaForCurrentRotation() + val chipTop = ((animationWindowView.parent as View).height - it.view.measuredHeight) / 2 + val chipBottom = chipTop + it.view.measuredHeight + val chipRight: Int + val chipLeft: Int + when (animationDirection) { + LEFT -> { + chipRight = contentRect.right + chipLeft = contentRect.right - it.chipWidth + } + else /* RIGHT */ -> { + chipLeft = contentRect.left + chipRight = contentRect.left + it.chipWidth + } } + chipBounds = Rect(chipLeft, chipTop, chipRight, chipBottom) } } @@ -117,16 +127,21 @@ class SystemEventChipAnimationController @Inject constructor( interpolator = null addUpdateListener { currentAnimatedView?.view?.alpha = animatedValue as Float } } + currentAnimatedView?.contentView?.alpha = 0f + val contentAlphaIn = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 10.frames + duration = 10.frames + interpolator = null + addUpdateListener { currentAnimatedView?.contentView?.alpha = animatedValue as Float } + } val moveIn = ValueAnimator.ofInt(chipMinWidth, chipWidth).apply { startDelay = 7.frames duration = 23.frames interpolator = STATUS_BAR_X_MOVE_IN - addUpdateListener { - updateAnimatedViewBoundsWidth(animatedValue as Int) - } + addUpdateListener { updateAnimatedViewBoundsWidth(animatedValue as Int) } } val animSet = AnimatorSet() - animSet.playTogether(alphaIn, moveIn) + animSet.playTogether(alphaIn, contentAlphaIn, moveIn) return animSet } @@ -139,7 +154,7 @@ class SystemEventChipAnimationController @Inject constructor( } finish.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { animationWindowView.removeView(currentAnimatedView!!.view) } }) @@ -152,7 +167,7 @@ class SystemEventChipAnimationController @Inject constructor( duration = 9.frames interpolator = STATUS_CHIP_WIDTH_TO_DOT_KEYFRAME_1 addUpdateListener { - updateAnimatedViewBoundsWidth(it.animatedValue as Int) + updateAnimatedViewBoundsWidth(animatedValue as Int) } } @@ -161,7 +176,7 @@ class SystemEventChipAnimationController @Inject constructor( duration = 20.frames interpolator = STATUS_CHIP_WIDTH_TO_DOT_KEYFRAME_2 addUpdateListener { - updateAnimatedViewBoundsWidth(it.animatedValue as Int) + updateAnimatedViewBoundsWidth(animatedValue as Int) } } @@ -174,7 +189,7 @@ class SystemEventChipAnimationController @Inject constructor( duration = 6.frames interpolator = STATUS_CHIP_HEIGHT_TO_DOT_KEYFRAME_1 addUpdateListener { - updateAnimatedViewBoundsHeight(it.animatedValue as Int, chipVerticalCenter) + updateAnimatedViewBoundsHeight(animatedValue as Int, chipVerticalCenter) } } @@ -183,7 +198,7 @@ class SystemEventChipAnimationController @Inject constructor( duration = 15.frames interpolator = STATUS_CHIP_HEIGHT_TO_DOT_KEYFRAME_2 addUpdateListener { - updateAnimatedViewBoundsHeight(it.animatedValue as Int, chipVerticalCenter) + updateAnimatedViewBoundsHeight(animatedValue as Int, chipVerticalCenter) } } @@ -210,15 +225,32 @@ class SystemEventChipAnimationController @Inject constructor( } private fun createMoveOutAnimationDefault(): Animator { + val alphaOut = ValueAnimator.ofFloat(1f, 0f).apply { + startDelay = 6.frames + duration = 6.frames + interpolator = null + addUpdateListener { currentAnimatedView?.view?.alpha = animatedValue as Float } + } + + val contentAlphaOut = ValueAnimator.ofFloat(1f, 0f).apply { + duration = 5.frames + interpolator = null + addUpdateListener { currentAnimatedView?.contentView?.alpha = animatedValue as Float } + } + val moveOut = ValueAnimator.ofInt(chipWidth, chipMinWidth).apply { duration = 23.frames + interpolator = STATUS_BAR_X_MOVE_OUT addUpdateListener { currentAnimatedView?.apply { - updateAnimatedViewBoundsWidth(it.animatedValue as Int) + updateAnimatedViewBoundsWidth(animatedValue as Int) } } } - return moveOut + + val animSet = AnimatorSet() + animSet.playTogether(alphaOut, contentAlphaOut, moveOut) + return animSet } private fun init() { @@ -239,11 +271,15 @@ class SystemEventChipAnimationController @Inject constructor( it.marginEnd = marginEnd } - private fun initializeAnimRect() = animRect.set( - chipLeft, - currentAnimatedView!!.view.top, - chipRight, - currentAnimatedView!!.view.bottom) + private fun initializeAnimRect() = if (featureFlags.isEnabled(Flags.PLUG_IN_STATUS_BAR_CHIP)) { + animRect.set(chipBounds) + } else { + animRect.set( + chipLeft, + currentAnimatedView!!.view.top, + chipRight, + currentAnimatedView!!.view.bottom) + } /** * To be called during an animation, sets the width and updates the current animated chip view @@ -296,6 +332,8 @@ class SystemEventChipAnimationController @Inject constructor( interface BackgroundAnimatableView { val view: View // Since this can't extend View, add a view prop get() = this as View + val contentView: View? // This will be alpha faded during appear and disappear animation + get() = null val chipWidth: Int get() = view.measuredWidth fun setBoundsForAnimation(l: Int, t: Int, r: Int, b: Int) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt index fde5d39db7e3e..26fd2307c59d7 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt @@ -16,11 +16,14 @@ package com.android.systemui.statusbar.events +import android.annotation.IntRange import android.content.Context import android.provider.DeviceConfig import android.provider.DeviceConfig.NAMESPACE_PRIVACY import com.android.systemui.R import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.privacy.PrivacyChipBuilder import com.android.systemui.privacy.PrivacyItem import com.android.systemui.privacy.PrivacyItemController @@ -37,21 +40,18 @@ class SystemEventCoordinator @Inject constructor( private val systemClock: SystemClock, private val batteryController: BatteryController, private val privacyController: PrivacyItemController, - private val context: Context + private val context: Context, + private val featureFlags: FeatureFlags ) { private lateinit var scheduler: SystemStatusAnimationScheduler fun startObserving() { - /* currently unused batteryController.addCallback(batteryStateListener) - */ privacyController.addCallback(privacyStateListener) } fun stopObserving() { - /* currently unused batteryController.removeCallback(batteryStateListener) - */ privacyController.removeCallback(privacyStateListener) } @@ -59,12 +59,14 @@ class SystemEventCoordinator @Inject constructor( this.scheduler = s } - fun notifyPluggedIn() { - scheduler.onStatusEvent(BatteryEvent()) + fun notifyPluggedIn(@IntRange(from = 0, to = 100) batteryLevel: Int) { + if (featureFlags.isEnabled(Flags.PLUG_IN_STATUS_BAR_CHIP)) { + scheduler.onStatusEvent(BatteryEvent(batteryLevel)) + } } fun notifyPrivacyItemsEmpty() { - scheduler.setShouldShowPersistentPrivacyIndicator(false) + scheduler.removePersistentDot() } fun notifyPrivacyItemsChanged(showAnimation: Boolean = true) { @@ -79,25 +81,25 @@ class SystemEventCoordinator @Inject constructor( } private val batteryStateListener = object : BatteryController.BatteryStateChangeCallback { - var plugged = false - var stateKnown = false + private var plugged = false + private var stateKnown = false override fun onBatteryLevelChanged(level: Int, pluggedIn: Boolean, charging: Boolean) { if (!stateKnown) { stateKnown = true plugged = pluggedIn - notifyListeners() + notifyListeners(level) return } if (plugged != pluggedIn) { plugged = pluggedIn - notifyListeners() + notifyListeners(level) } } - private fun notifyListeners() { + private fun notifyListeners(@IntRange(from = 0, to = 100) batteryLevel: Int) { // We only care about the plugged in status - if (plugged) notifyPluggedIn() + if (plugged) notifyPluggedIn(batteryLevel) } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationScheduler.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationScheduler.kt index 197cf5608cf58..2a18f1f51acea 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationScheduler.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationScheduler.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2023 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. @@ -16,302 +16,21 @@ package com.android.systemui.statusbar.events -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet import android.annotation.IntDef -import android.os.Process -import android.provider.DeviceConfig -import android.util.Log -import android.view.animation.PathInterpolator +import androidx.core.animation.Animator +import androidx.core.animation.AnimatorSet +import androidx.core.animation.PathInterpolator import com.android.systemui.Dumpable -import com.android.systemui.dagger.SysUISingleton -import com.android.systemui.dagger.qualifiers.Main -import com.android.systemui.dump.DumpManager import com.android.systemui.statusbar.policy.CallbackController -import com.android.systemui.statusbar.window.StatusBarWindowController -import com.android.systemui.util.Assert -import com.android.systemui.util.concurrency.DelayableExecutor -import com.android.systemui.util.time.SystemClock -import java.io.PrintWriter -import javax.inject.Inject -/** - * Dead-simple scheduler for system status events. Obeys the following principles (all values TBD): - * - Avoiding log spam by only allowing 12 events per minute (1event/5s) - * - Waits 100ms to schedule any event for debouncing/prioritization - * - Simple prioritization: Privacy > Battery > connectivity (encoded in [StatusEvent]) - * - Only schedules a single event, and throws away lowest priority events - * - * There are 4 basic stages of animation at play here: - * 1. System chrome animation OUT - * 2. Chip animation IN - * 3. Chip animation OUT; potentially into a dot - * 4. System chrome animation IN - * - * Thus we can keep all animations synchronized with two separate ValueAnimators, one for system - * chrome and the other for the chip. These can animate from 0,1 and listeners can parameterize - * their respective views based on the progress of the animator. Interpolation differences TBD - */ -@SysUISingleton -open class SystemStatusAnimationScheduler @Inject constructor( - private val coordinator: SystemEventCoordinator, - private val chipAnimationController: SystemEventChipAnimationController, - private val statusBarWindowController: StatusBarWindowController, - private val dumpManager: DumpManager, - private val systemClock: SystemClock, - @Main private val executor: DelayableExecutor -) : CallbackController, Dumpable { +interface SystemStatusAnimationScheduler : + CallbackController, Dumpable { - companion object { - private const val PROPERTY_ENABLE_IMMERSIVE_INDICATOR = "enable_immersive_indicator" - } - public fun isImmersiveIndicatorEnabled(): Boolean { - return DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_PRIVACY, - PROPERTY_ENABLE_IMMERSIVE_INDICATOR, true) - } + @SystemAnimationState fun getAnimationState(): Int - @SystemAnimationState var animationState: Int = IDLE - private set + fun onStatusEvent(event: StatusEvent) - /** True if the persistent privacy dot should be active */ - var hasPersistentDot = false - protected set - - private var scheduledEvent: StatusEvent? = null - private var cancelExecutionRunnable: Runnable? = null - private val listeners = mutableSetOf() - - fun getListeners(): MutableSet { - return listeners - } - - init { - coordinator.attachScheduler(this) - dumpManager.registerDumpable(TAG, this) - } - - open fun onStatusEvent(event: StatusEvent) { - // Ignore any updates until the system is up and running - if (isTooEarly() || !isImmersiveIndicatorEnabled()) { - return - } - - // Don't deal with threading for now (no need let's be honest) - Assert.isMainThread() - if ((event.priority > scheduledEvent?.priority ?: -1) && - animationState != ANIMATING_OUT && - (animationState != SHOWING_PERSISTENT_DOT && event.forceVisible)) { - // events can only be scheduled if a higher priority or no other event is in progress - if (DEBUG) { - Log.d(TAG, "scheduling event $event") - } - - scheduleEvent(event) - } else if (scheduledEvent?.shouldUpdateFromEvent(event) == true) { - if (DEBUG) { - Log.d(TAG, "updating current event from: $event. animationState=$animationState") - } - scheduledEvent?.updateFromEvent(event) - if (event.forceVisible) { - hasPersistentDot = true - // If we missed the chance to show the persistent dot, do it now - if (animationState == IDLE) { - notifyTransitionToPersistentDot() - } - } - } else { - if (DEBUG) { - Log.d(TAG, "ignoring event $event") - } - } - } - - private fun clearDotIfVisible() { - notifyHidePersistentDot() - } - - fun setShouldShowPersistentPrivacyIndicator(should: Boolean) { - if (hasPersistentDot == should || !isImmersiveIndicatorEnabled()) { - return - } - - hasPersistentDot = should - - if (!hasPersistentDot) { - clearDotIfVisible() - } - } - - public fun isTooEarly(): Boolean { - return systemClock.uptimeMillis() - Process.getStartUptimeMillis() < MIN_UPTIME - } - - /** - * Clear the scheduled event (if any) and schedule a new one - */ - private fun scheduleEvent(event: StatusEvent) { - scheduledEvent = event - - if (event.forceVisible) { - hasPersistentDot = true - } - - // If animations are turned off, we'll transition directly to the dot - if (!event.showAnimation && event.forceVisible) { - notifyTransitionToPersistentDot() - scheduledEvent = null - return - } - - chipAnimationController.prepareChipAnimation(scheduledEvent!!.viewCreator) - animationState = ANIMATION_QUEUED - executor.executeDelayed({ - runChipAnimation() - }, DEBOUNCE_DELAY) - } - - /** - * 1. Define a total budget for the chip animation (1500ms) - * 2. Send out callbacks to listeners so that they can generate animations locally - * 3. Update the scheduler state so that clients know where we are - * 4. Maybe: provide scaffolding such as: dot location, margins, etc - * 5. Maybe: define a maximum animation length and enforce it. Probably only doable if we - * collect all of the animators and run them together. - */ - private fun runChipAnimation() { - statusBarWindowController.setForceStatusBarVisible(true) - animationState = ANIMATING_IN - - val animSet = collectStartAnimations() - if (animSet.totalDuration > 500) { - throw IllegalStateException("System animation total length exceeds budget. " + - "Expected: 500, actual: ${animSet.totalDuration}") - } - animSet.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - animationState = RUNNING_CHIP_ANIM - } - }) - animSet.start() - - executor.executeDelayed({ - val animSet2 = collectFinishAnimations() - animationState = ANIMATING_OUT - animSet2.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - animationState = if (hasPersistentDot) { - SHOWING_PERSISTENT_DOT - } else { - IDLE - } - - statusBarWindowController.setForceStatusBarVisible(false) - } - }) - animSet2.start() - scheduledEvent = null - }, DISPLAY_LENGTH) - } - - private fun collectStartAnimations(): AnimatorSet { - val animators = mutableListOf() - listeners.forEach { listener -> - listener.onSystemEventAnimationBegin()?.let { anim -> - animators.add(anim) - } - } - animators.add(chipAnimationController.onSystemEventAnimationBegin()) - val animSet = AnimatorSet().also { - it.playTogether(animators) - } - - return animSet - } - - private fun collectFinishAnimations(): AnimatorSet { - val animators = mutableListOf() - listeners.forEach { listener -> - listener.onSystemEventAnimationFinish(hasPersistentDot)?.let { anim -> - animators.add(anim) - } - } - animators.add(chipAnimationController.onSystemEventAnimationFinish(hasPersistentDot)) - if (hasPersistentDot) { - val dotAnim = notifyTransitionToPersistentDot() - if (dotAnim != null) { - animators.add(dotAnim) - } - } - val animSet = AnimatorSet().also { - it.playTogether(animators) - } - - return animSet - } - - private fun notifyTransitionToPersistentDot(): Animator? { - val anims: List = listeners.mapNotNull { - it.onSystemStatusAnimationTransitionToPersistentDot(scheduledEvent?.contentDescription) - } - if (anims.isNotEmpty()) { - val aSet = AnimatorSet() - aSet.playTogether(anims) - return aSet - } - - return null - } - - private fun notifyHidePersistentDot(): Animator? { - val anims: List = listeners.mapNotNull { - it.onHidePersistentDot() - } - - if (animationState == SHOWING_PERSISTENT_DOT) { - animationState = IDLE - } - - if (anims.isNotEmpty()) { - val aSet = AnimatorSet() - aSet.playTogether(anims) - return aSet - } - - return null - } - - override fun addCallback(listener: SystemStatusAnimationCallback) { - Assert.isMainThread() - - if (listeners.isEmpty()) { - coordinator.startObserving() - } - listeners.add(listener) - } - - override fun removeCallback(listener: SystemStatusAnimationCallback) { - Assert.isMainThread() - - listeners.remove(listener) - if (listeners.isEmpty()) { - coordinator.stopObserving() - } - } - - override fun dump(pw: PrintWriter, args: Array) { - pw.println("Scheduled event: $scheduledEvent") - pw.println("Has persistent privacy dot: $hasPersistentDot") - pw.println("Animation state: $animationState") - pw.println("Listeners:") - if (listeners.isEmpty()) { - pw.println("(none)") - } else { - listeners.forEach { - pw.println(" $it") - } - } - } + fun removePersistentDot() } /** @@ -337,6 +56,7 @@ interface SystemStatusAnimationCallback { @JvmDefault fun onHidePersistentDot(): Animator? { return null } } + /** * Animation state IntDef */ @@ -354,7 +74,7 @@ interface SystemStatusAnimationCallback { annotation class SystemAnimationState /** No animation is in progress */ -const val IDLE = 0 +@SystemAnimationState const val IDLE = 0 /** An animation is queued, and awaiting the debounce period */ const val ANIMATION_QUEUED = 1 /** System is animating out, and chip is animating in */ @@ -379,20 +99,16 @@ val STATUS_CHIP_HEIGHT_TO_DOT_KEYFRAME_1 = PathInterpolator(0.4f, 0f, 0.17f, 1f) val STATUS_CHIP_HEIGHT_TO_DOT_KEYFRAME_2 = PathInterpolator(0.3f, 0f, 0f, 1f) val STATUS_CHIP_MOVE_TO_DOT = PathInterpolator(0f, 0f, 0.05f, 1f) -private const val TAG = "SystemStatusAnimationScheduler" -private const val DEBOUNCE_DELAY = 100L +internal const val DEBOUNCE_DELAY = 100L /** * The total time spent on the chip animation is 1500ms, broken up into 3 sections: - * - 500ms to animate the chip in (including animating system icons away) - * - 500ms holding the chip on screen - * - 500ms to animate the chip away (and system icons back) - * - * So DISPLAY_LENGTH should be the sum of the first 2 phases, while the final 500ms accounts for - * the actual animation + * - 500ms to animate the chip in (including animating system icons away) + * - 500ms holding the chip on screen + * - 500ms to animate the chip away (and system icons back) */ -private const val DISPLAY_LENGTH = 1000L +internal const val APPEAR_ANIMATION_DURATION = 500L +internal const val DISPLAY_LENGTH = 3000L +internal const val DISAPPEAR_ANIMATION_DURATION = 500L -private const val MIN_UPTIME: Long = 5 * 1000 - -private const val DEBUG = false +internal const val MIN_UPTIME: Long = 5 * 1000 \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImpl.kt new file mode 100644 index 0000000000000..f7a4feafee25f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImpl.kt @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2023 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.statusbar.events + +import android.os.Process +import android.provider.DeviceConfig +import android.util.Log +import androidx.core.animation.Animator +import androidx.core.animation.AnimatorListenerAdapter +import androidx.core.animation.AnimatorSet +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dump.DumpManager +import com.android.systemui.statusbar.window.StatusBarWindowController +import com.android.systemui.util.Assert +import com.android.systemui.util.time.SystemClock +import java.io.PrintWriter +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** + * Scheduler for system status events. Obeys the following principles: + * ``` + * - Waits 100 ms to schedule any event for debouncing/prioritization + * - Simple prioritization: Privacy > Battery > Connectivity (encoded in [StatusEvent]) + * - Only schedules a single event, and throws away lowest priority events + * ``` + * + * There are 4 basic stages of animation at play here: + * ``` + * 1. System chrome animation OUT + * 2. Chip animation IN + * 3. Chip animation OUT; potentially into a dot + * 4. System chrome animation IN + * ``` + * + * Thus we can keep all animations synchronized with two separate ValueAnimators, one for system + * chrome and the other for the chip. These can animate from 0,1 and listeners can parameterize + * their respective views based on the progress of the animator. + */ +@OptIn(FlowPreview::class) +open class SystemStatusAnimationSchedulerImpl +@Inject +constructor( + private val coordinator: SystemEventCoordinator, + private val chipAnimationController: SystemEventChipAnimationController, + private val statusBarWindowController: StatusBarWindowController, + dumpManager: DumpManager, + private val systemClock: SystemClock, + @Application private val coroutineScope: CoroutineScope +) : SystemStatusAnimationScheduler { + + companion object { + private const val PROPERTY_ENABLE_IMMERSIVE_INDICATOR = "enable_immersive_indicator" + } + + /** Contains the StatusEvent that is going to be displayed next. */ + private var scheduledEvent = MutableStateFlow(null) + + /** + * The currently displayed status event. (This is null in all states except ANIMATING_IN and + * CHIP_ANIMATION_RUNNING) + */ + private var currentlyDisplayedEvent: StatusEvent? = null + + /** StateFlow holding the current [SystemAnimationState] at any time. */ + private var animationState = MutableStateFlow(IDLE) + + /** True if the persistent privacy dot should be active */ + var hasPersistentDot = false + protected set + + /** Set of currently registered listeners */ + protected val listeners = mutableSetOf() + + /** The job that is controlling the animators of the currently displayed status event. */ + private var currentlyRunningAnimationJob: Job? = null + + /** The job that is controlling the animators when an event is cancelled. */ + private var eventCancellationJob: Job? = null + + init { + coordinator.attachScheduler(this) + dumpManager.registerCriticalDumpable(TAG, this) + + coroutineScope.launch { + // Wait for animationState to become ANIMATION_QUEUED and scheduledEvent to be non null. + // Once this combination is stable for at least DEBOUNCE_DELAY, then start a chip enter + // animation + animationState + .combine(scheduledEvent) { animationState, scheduledEvent -> + Pair(animationState, scheduledEvent) + } + .debounce(DEBOUNCE_DELAY) + .collect { (animationState, event) -> + if (animationState == ANIMATION_QUEUED && event != null) { + startAnimationLifecycle(event) + scheduledEvent.value = null + } + } + } + } + + @SystemAnimationState override fun getAnimationState(): Int = animationState.value + + override fun onStatusEvent(event: StatusEvent) { + Assert.isMainThread() + + // Ignore any updates until the system is up and running + if (isTooEarly() || !isImmersiveIndicatorEnabled()) { + return + } + + if ( + (event.priority > (scheduledEvent.value?.priority ?: -1)) && + (event.priority > (currentlyDisplayedEvent?.priority ?: -1)) && + !hasPersistentDot + ) { + // a event can only be scheduled if no other event is in progress or it has a higher + // priority. If a persistent dot is currently displayed, don't schedule the event. + if (DEBUG) { + Log.d(TAG, "scheduling event $event") + } + + scheduleEvent(event) + } else if (currentlyDisplayedEvent?.shouldUpdateFromEvent(event) == true) { + if (DEBUG) { + Log.d( + TAG, + "updating current event from: $event. animationState=${animationState.value}" + ) + } + currentlyDisplayedEvent?.updateFromEvent(event) + } else if (scheduledEvent.value?.shouldUpdateFromEvent(event) == true) { + if (DEBUG) { + Log.d( + TAG, + "updating scheduled event from: $event. animationState=${animationState.value}" + ) + } + scheduledEvent.value?.updateFromEvent(event) + } else { + if (DEBUG) { + Log.d(TAG, "ignoring event $event") + } + } + } + + override fun removePersistentDot() { + Assert.isMainThread() + + // If there is an event scheduled currently, set its forceVisible flag to false, such that + // it will never transform into a persistent dot + scheduledEvent.value?.forceVisible = false + + // Nothing else to do if hasPersistentDot is already false + if (!hasPersistentDot) return + // Set hasPersistentDot to false. If the animationState is anything before ANIMATING_OUT, + // the disappear animation will not animate into a dot but remove the chip entirely + hasPersistentDot = false + // if we are currently showing a persistent dot, hide it + if (animationState.value == SHOWING_PERSISTENT_DOT) notifyHidePersistentDot() + // if we are currently animating into a dot, wait for the animation to finish and then hide + // the dot + if (animationState.value == ANIMATING_OUT) { + coroutineScope.launch { + withTimeout(DISAPPEAR_ANIMATION_DURATION) { + animationState.first { it == SHOWING_PERSISTENT_DOT || it == ANIMATION_QUEUED } + notifyHidePersistentDot() + } + } + } + } + + protected fun isTooEarly(): Boolean { + return systemClock.uptimeMillis() - Process.getStartUptimeMillis() < MIN_UPTIME + } + + protected fun isImmersiveIndicatorEnabled(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_PRIVACY, + PROPERTY_ENABLE_IMMERSIVE_INDICATOR, + true + ) + } + + /** Clear the scheduled event (if any) and schedule a new one */ + private fun scheduleEvent(event: StatusEvent) { + scheduledEvent.value = event + if (currentlyDisplayedEvent != null && eventCancellationJob?.isActive != true) { + // cancel the currently displayed event. As soon as the event is animated out, the + // scheduled event will be displayed. + cancelCurrentlyDisplayedEvent() + return + } + if (animationState.value == IDLE) { + // If we are in IDLE state, set it to ANIMATION_QUEUED now + animationState.value = ANIMATION_QUEUED + } + } + + /** + * Cancels the currently displayed event by animating it out. This function should only be + * called if the animationState is ANIMATING_IN or RUNNING_CHIP_ANIM, or in other words whenever + * currentlyRunningEvent is not null + */ + private fun cancelCurrentlyDisplayedEvent() { + eventCancellationJob = + coroutineScope.launch { + withTimeout(APPEAR_ANIMATION_DURATION) { + // wait for animationState to become RUNNING_CHIP_ANIM, then cancel the running + // animation job and run the disappear animation immediately + animationState.first { it == RUNNING_CHIP_ANIM } + currentlyRunningAnimationJob?.cancel() + runChipDisappearAnimation() + } + } + } + + /** + * Takes the currently scheduled Event and (using the coroutineScope) animates it in and out + * again after displaying it for DISPLAY_LENGTH ms. This function should only be called if there + * is an event scheduled (and currentlyDisplayedEvent is null) + */ + private fun startAnimationLifecycle(event: StatusEvent) { + Assert.isMainThread() + hasPersistentDot = event.forceVisible + + if (!event.showAnimation && event.forceVisible) { + // If animations are turned off, we'll transition directly to the dot + animationState.value = SHOWING_PERSISTENT_DOT + notifyTransitionToPersistentDot() + return + } + + currentlyDisplayedEvent = event + + chipAnimationController.prepareChipAnimation(event.viewCreator) + currentlyRunningAnimationJob = + coroutineScope.launch { + runChipAppearAnimation() + delay(APPEAR_ANIMATION_DURATION + DISPLAY_LENGTH) + runChipDisappearAnimation() + } + } + + /** + * 1. Define a total budget for the chip animation (1500ms) + * 2. Send out callbacks to listeners so that they can generate animations locally + * 3. Update the scheduler state so that clients know where we are + * 4. Maybe: provide scaffolding such as: dot location, margins, etc + * 5. Maybe: define a maximum animation length and enforce it. Probably only doable if we + * collect all of the animators and run them together. + */ + private fun runChipAppearAnimation() { + Assert.isMainThread() + if (hasPersistentDot) { + statusBarWindowController.setForceStatusBarVisible(true) + } + animationState.value = ANIMATING_IN + + val animSet = collectStartAnimations() + if (animSet.totalDuration > 500) { + throw IllegalStateException( + "System animation total length exceeds budget. " + + "Expected: 500, actual: ${animSet.totalDuration}" + ) + } + animSet.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animationState.value = RUNNING_CHIP_ANIM + } + } + ) + animSet.start() + } + + private fun runChipDisappearAnimation() { + Assert.isMainThread() + val animSet2 = collectFinishAnimations() + animationState.value = ANIMATING_OUT + animSet2.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animationState.value = + when { + hasPersistentDot -> SHOWING_PERSISTENT_DOT + scheduledEvent.value != null -> ANIMATION_QUEUED + else -> IDLE + } + statusBarWindowController.setForceStatusBarVisible(false) + } + } + ) + animSet2.start() + + // currentlyDisplayedEvent is set to null before the animation has ended such that new + // events can be scheduled during the disappear animation. We don't want to miss e.g. a new + // privacy event being scheduled during the disappear animation, otherwise we could end up + // with e.g. an active microphone but no privacy dot being displayed. + currentlyDisplayedEvent = null + } + + private fun collectStartAnimations(): AnimatorSet { + val animators = mutableListOf() + listeners.forEach { listener -> + listener.onSystemEventAnimationBegin()?.let { anim -> animators.add(anim) } + } + animators.add(chipAnimationController.onSystemEventAnimationBegin()) + + return AnimatorSet().also { it.playTogether(animators) } + } + + private fun collectFinishAnimations(): AnimatorSet { + val animators = mutableListOf() + listeners.forEach { listener -> + listener.onSystemEventAnimationFinish(hasPersistentDot)?.let { anim -> + animators.add(anim) + } + } + animators.add(chipAnimationController.onSystemEventAnimationFinish(hasPersistentDot)) + if (hasPersistentDot) { + val dotAnim = notifyTransitionToPersistentDot() + if (dotAnim != null) { + animators.add(dotAnim) + } + } + + return AnimatorSet().also { it.playTogether(animators) } + } + + private fun notifyTransitionToPersistentDot(): Animator? { + val anims: List = + listeners.mapNotNull { + it.onSystemStatusAnimationTransitionToPersistentDot( + currentlyDisplayedEvent?.contentDescription + ) + } + if (anims.isNotEmpty()) { + val aSet = AnimatorSet() + aSet.playTogether(anims) + return aSet + } + + return null + } + + private fun notifyHidePersistentDot(): Animator? { + Assert.isMainThread() + val anims: List = listeners.mapNotNull { it.onHidePersistentDot() } + + if (animationState.value == SHOWING_PERSISTENT_DOT) { + if (scheduledEvent.value != null) { + animationState.value = ANIMATION_QUEUED + } else { + animationState.value = IDLE + } + } + + if (anims.isNotEmpty()) { + val aSet = AnimatorSet() + aSet.playTogether(anims) + return aSet + } + + return null + } + + override fun addCallback(listener: SystemStatusAnimationCallback) { + Assert.isMainThread() + + if (listeners.isEmpty()) { + coordinator.startObserving() + } + listeners.add(listener) + } + + override fun removeCallback(listener: SystemStatusAnimationCallback) { + Assert.isMainThread() + + listeners.remove(listener) + if (listeners.isEmpty()) { + coordinator.stopObserving() + } + } + + override fun dump(pw: PrintWriter, args: Array) { + pw.println("Scheduled event: ${scheduledEvent.value}") + pw.println("Currently displayed event: $currentlyDisplayedEvent") + pw.println("Has persistent privacy dot: $hasPersistentDot") + pw.println("Animation state: ${animationState.value}") + pw.println("Listeners:") + if (listeners.isEmpty()) { + pw.println("(none)") + } else { + listeners.forEach { pw.println(" $it") } + } + } +} + +private const val DEBUG = false +private const val TAG = "SystemStatusAnimationSchedulerImpl" diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerLegacyImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerLegacyImpl.kt new file mode 100644 index 0000000000000..64b7ac9ee0a19 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerLegacyImpl.kt @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2021 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.statusbar.events + +import android.os.Process +import android.provider.DeviceConfig +import android.util.Log +import androidx.core.animation.Animator +import androidx.core.animation.AnimatorListenerAdapter +import androidx.core.animation.AnimatorSet +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.dump.DumpManager +import com.android.systemui.statusbar.window.StatusBarWindowController +import com.android.systemui.util.Assert +import com.android.systemui.util.concurrency.DelayableExecutor +import com.android.systemui.util.time.SystemClock +import java.io.PrintWriter +import javax.inject.Inject + +/** + * Dead-simple scheduler for system status events. Obeys the following principles (all values TBD): + * ``` + * - Avoiding log spam by only allowing 12 events per minute (1event/5s) + * - Waits 100ms to schedule any event for debouncing/prioritization + * - Simple prioritization: Privacy > Battery > connectivity (encoded in [StatusEvent]) + * - Only schedules a single event, and throws away lowest priority events + * ``` + * There are 4 basic stages of animation at play here: + * ``` + * 1. System chrome animation OUT + * 2. Chip animation IN + * 3. Chip animation OUT; potentially into a dot + * 4. System chrome animation IN + * ``` + * Thus we can keep all animations synchronized with two separate ValueAnimators, one for system + * chrome and the other for the chip. These can animate from 0,1 and listeners can parameterize + * their respective views based on the progress of the animator. Interpolation differences TBD + */ +open class SystemStatusAnimationSchedulerLegacyImpl +@Inject +constructor( + private val coordinator: SystemEventCoordinator, + private val chipAnimationController: SystemEventChipAnimationController, + private val statusBarWindowController: StatusBarWindowController, + private val dumpManager: DumpManager, + private val systemClock: SystemClock, + @Main private val executor: DelayableExecutor +) : SystemStatusAnimationScheduler { + + companion object { + private const val PROPERTY_ENABLE_IMMERSIVE_INDICATOR = "enable_immersive_indicator" + } + + fun isImmersiveIndicatorEnabled(): Boolean { + return DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_PRIVACY, + PROPERTY_ENABLE_IMMERSIVE_INDICATOR, + true + ) + } + + @SystemAnimationState private var animationState: Int = IDLE + + /** True if the persistent privacy dot should be active */ + var hasPersistentDot = false + protected set + + private var scheduledEvent: StatusEvent? = null + + val listeners = mutableSetOf() + + init { + coordinator.attachScheduler(this) + dumpManager.registerDumpable(TAG, this) + } + + @SystemAnimationState override fun getAnimationState() = animationState + + override fun onStatusEvent(event: StatusEvent) { + // Ignore any updates until the system is up and running + if (isTooEarly() || !isImmersiveIndicatorEnabled()) { + return + } + + // Don't deal with threading for now (no need let's be honest) + Assert.isMainThread() + if ( + (event.priority > (scheduledEvent?.priority ?: -1)) && + animationState != ANIMATING_OUT && + animationState != SHOWING_PERSISTENT_DOT + ) { + // events can only be scheduled if a higher priority or no other event is in progress + if (DEBUG) { + Log.d(TAG, "scheduling event $event") + } + + scheduleEvent(event) + } else if (scheduledEvent?.shouldUpdateFromEvent(event) == true) { + if (DEBUG) { + Log.d(TAG, "updating current event from: $event. animationState=$animationState") + } + scheduledEvent?.updateFromEvent(event) + if (event.forceVisible) { + hasPersistentDot = true + // If we missed the chance to show the persistent dot, do it now + if (animationState == IDLE) { + notifyTransitionToPersistentDot() + } + } + } else { + if (DEBUG) { + Log.d(TAG, "ignoring event $event") + } + } + } + + override fun removePersistentDot() { + if (!hasPersistentDot || !isImmersiveIndicatorEnabled()) { + return + } + + hasPersistentDot = false + notifyHidePersistentDot() + return + } + + fun isTooEarly(): Boolean { + return systemClock.uptimeMillis() - Process.getStartUptimeMillis() < MIN_UPTIME + } + + /** Clear the scheduled event (if any) and schedule a new one */ + private fun scheduleEvent(event: StatusEvent) { + scheduledEvent = event + + if (event.forceVisible) { + hasPersistentDot = true + } + + // If animations are turned off, we'll transition directly to the dot + if (!event.showAnimation && event.forceVisible) { + notifyTransitionToPersistentDot() + scheduledEvent = null + return + } + + chipAnimationController.prepareChipAnimation(scheduledEvent!!.viewCreator) + animationState = ANIMATION_QUEUED + executor.executeDelayed({ runChipAnimation() }, DEBOUNCE_DELAY) + } + + /** + * 1. Define a total budget for the chip animation (1500ms) + * 2. Send out callbacks to listeners so that they can generate animations locally + * 3. Update the scheduler state so that clients know where we are + * 4. Maybe: provide scaffolding such as: dot location, margins, etc + * 5. Maybe: define a maximum animation length and enforce it. Probably only doable if we + * collect all of the animators and run them together. + */ + private fun runChipAnimation() { + statusBarWindowController.setForceStatusBarVisible(true) + animationState = ANIMATING_IN + + val animSet = collectStartAnimations() + if (animSet.totalDuration > 500) { + throw IllegalStateException( + "System animation total length exceeds budget. " + + "Expected: 500, actual: ${animSet.totalDuration}" + ) + } + animSet.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animationState = RUNNING_CHIP_ANIM + } + } + ) + animSet.start() + + executor.executeDelayed( + { + val animSet2 = collectFinishAnimations() + animationState = ANIMATING_OUT + animSet2.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animationState = + if (hasPersistentDot) { + SHOWING_PERSISTENT_DOT + } else { + IDLE + } + + statusBarWindowController.setForceStatusBarVisible(false) + } + } + ) + animSet2.start() + scheduledEvent = null + }, + DISPLAY_LENGTH + ) + } + + private fun collectStartAnimations(): AnimatorSet { + val animators = mutableListOf() + listeners.forEach { listener -> + listener.onSystemEventAnimationBegin()?.let { anim -> animators.add(anim) } + } + animators.add(chipAnimationController.onSystemEventAnimationBegin()) + val animSet = AnimatorSet().also { it.playTogether(animators) } + + return animSet + } + + private fun collectFinishAnimations(): AnimatorSet { + val animators = mutableListOf() + listeners.forEach { listener -> + listener.onSystemEventAnimationFinish(hasPersistentDot)?.let { anim -> + animators.add(anim) + } + } + animators.add(chipAnimationController.onSystemEventAnimationFinish(hasPersistentDot)) + if (hasPersistentDot) { + val dotAnim = notifyTransitionToPersistentDot() + if (dotAnim != null) { + animators.add(dotAnim) + } + } + val animSet = AnimatorSet().also { it.playTogether(animators) } + + return animSet + } + + private fun notifyTransitionToPersistentDot(): Animator? { + val anims: List = + listeners.mapNotNull { + it.onSystemStatusAnimationTransitionToPersistentDot( + scheduledEvent?.contentDescription + ) + } + if (anims.isNotEmpty()) { + val aSet = AnimatorSet() + aSet.playTogether(anims) + return aSet + } + + return null + } + + private fun notifyHidePersistentDot(): Animator? { + val anims: List = listeners.mapNotNull { it.onHidePersistentDot() } + + if (animationState == SHOWING_PERSISTENT_DOT) { + animationState = IDLE + } + + if (anims.isNotEmpty()) { + val aSet = AnimatorSet() + aSet.playTogether(anims) + return aSet + } + + return null + } + + override fun addCallback(listener: SystemStatusAnimationCallback) { + Assert.isMainThread() + + if (listeners.isEmpty()) { + coordinator.startObserving() + } + listeners.add(listener) + } + + override fun removeCallback(listener: SystemStatusAnimationCallback) { + Assert.isMainThread() + + listeners.remove(listener) + if (listeners.isEmpty()) { + coordinator.stopObserving() + } + } + + override fun dump(pw: PrintWriter, args: Array) { + pw.println("Scheduled event: $scheduledEvent") + pw.println("Has persistent privacy dot: $hasPersistentDot") + pw.println("Animation state: $animationState") + pw.println("Listeners:") + if (listeners.isEmpty()) { + pw.println("(none)") + } else { + listeners.forEach { pw.println(" $it") } + } + } +} + +private const val DEBUG = false +private const val TAG = "SystemStatusAnimationSchedulerLegacyImpl" diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java index cba0897408ddf..753032c2ee017 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java @@ -21,9 +21,6 @@ import static android.app.StatusBarManager.DISABLE_SYSTEM_INFO; import static com.android.systemui.statusbar.StatusBarState.KEYGUARD; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; import android.content.res.Configuration; import android.content.res.Resources; import android.database.ContentObserver; @@ -36,13 +33,16 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import androidx.core.animation.Animator; +import androidx.core.animation.AnimatorListenerAdapter; +import androidx.core.animation.ValueAnimator; import com.android.keyguard.CarrierTextController; import com.android.keyguard.KeyguardUpdateMonitor; import com.android.keyguard.KeyguardUpdateMonitorCallback; import com.android.keyguard.logging.KeyguardLogger; import com.android.systemui.R; -import com.android.systemui.animation.Interpolators; +import com.android.systemui.animation.InterpolatorsAndroidX; import com.android.systemui.battery.BatteryMeterViewController; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.plugins.log.LogLevel; @@ -166,7 +166,8 @@ public class KeyguardStatusBarViewController extends ViewController { - mKeyguardStatusBarAnimateAlpha = (float) animation.getAnimatedValue(); + mKeyguardStatusBarAnimateAlpha = + (float) ((ValueAnimator) animation).getAnimatedValue(); updateViewState(); }; @@ -434,7 +435,7 @@ public class KeyguardStatusBarViewController extends ViewController - animatedView.translationX = -(translationXIn * animation.animatedValue as Float) + val moveOut = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 23.frames + interpolator = STATUS_BAR_X_MOVE_OUT + addUpdateListener { + animatedView.translationX = -(translationXIn * animatedValue as Float) + } } - val alphaOut = ValueAnimator.ofFloat(1f, 0f).setDuration(8.frames) - alphaOut.interpolator = null - alphaOut.addUpdateListener { animation: ValueAnimator -> - animatedView.alpha = animation.animatedValue as Float + val alphaOut = ValueAnimator.ofFloat(1f, 0f).apply { + duration = 8.frames + interpolator = null + addUpdateListener { + animatedView.alpha = animatedValue as Float + } } val animSet = AnimatorSet() @@ -64,17 +68,21 @@ class StatusBarSystemEventAnimator( override fun onSystemEventAnimationFinish(hasPersistentDot: Boolean): Animator { animatedView.translationX = translationXOut.toFloat() - val moveIn = ValueAnimator.ofFloat(1f, 0f).setDuration(28.frames) - moveIn.startDelay = 2.frames - moveIn.interpolator = STATUS_BAR_X_MOVE_IN - moveIn.addUpdateListener { animation: ValueAnimator -> - animatedView.translationX = translationXOut * animation.animatedValue as Float + val moveIn = ValueAnimator.ofFloat(1f, 0f).apply { + duration = 23.frames + startDelay = 7.frames + interpolator = STATUS_BAR_X_MOVE_IN + addUpdateListener { + animatedView.translationX = translationXOut * animatedValue as Float + } } - val alphaIn = ValueAnimator.ofFloat(0f, 1f).setDuration(10.frames) - alphaIn.startDelay = 4.frames - alphaIn.interpolator = null - alphaIn.addUpdateListener { animation: ValueAnimator -> - animatedView.alpha = animation.animatedValue as Float + val alphaIn = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 5.frames + startDelay = 11.frames + interpolator = null + addUpdateListener { + animatedView.alpha = animatedValue as Float + } } val animatorSet = AnimatorSet() diff --git a/packages/SystemUI/src/com/android/systemui/tv/TvSystemUIModule.java b/packages/SystemUI/src/com/android/systemui/tv/TvSystemUIModule.java index 82200c61eeb5a..360fc90a2d247 100644 --- a/packages/SystemUI/src/com/android/systemui/tv/TvSystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/tv/TvSystemUIModule.java @@ -53,6 +53,7 @@ import com.android.systemui.statusbar.NotificationListener; import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.NotificationLockscreenUserManagerImpl; import com.android.systemui.statusbar.NotificationShadeWindowController; +import com.android.systemui.statusbar.events.StatusBarEventsModule; import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider; import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager; import com.android.systemui.statusbar.phone.DozeServiceHost; @@ -93,6 +94,7 @@ import dagger.multibindings.IntoSet; PowerModule.class, QSModule.class, ReferenceScreenshotModule.class, + StatusBarEventsModule.class, VolumeModule.class, } ) diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/FakeStatusEvent.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/FakeStatusEvent.kt new file mode 100644 index 0000000000000..cd0646543e69d --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/FakeStatusEvent.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 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.statusbar.events + +/** + * This is a freely configurable implementation of [StatusEvent]. It is intended to be used in + * tests. + */ +class FakeStatusEvent( + override val viewCreator: ViewCreator, + override val priority: Int = 50, + override var forceVisible: Boolean = false, + override val showAnimation: Boolean = true, + override var contentDescription: String? = "", +) : StatusEvent diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImplTest.kt new file mode 100644 index 0000000000000..08a9f3139d71e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemStatusAnimationSchedulerImplTest.kt @@ -0,0 +1,470 @@ +/* + * Copyright (C) 2023 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.statusbar.events + +import android.graphics.Rect +import android.os.Process +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper.RunWithLooper +import android.view.View +import android.widget.FrameLayout +import androidx.core.animation.AnimatorTestRule +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.dump.DumpManager +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.privacy.OngoingPrivacyChip +import com.android.systemui.statusbar.BatteryStatusChip +import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider +import com.android.systemui.statusbar.window.StatusBarWindowController +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.time.FakeSystemClock +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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.Mock +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@RunWith(AndroidTestingRunner::class) +@RunWithLooper(setAsMainLooper = true) +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +class SystemStatusAnimationSchedulerImplTest : SysuiTestCase() { + + @Mock private lateinit var systemEventCoordinator: SystemEventCoordinator + @Mock private lateinit var statusBarWindowController: StatusBarWindowController + @Mock private lateinit var statusBarContentInsetProvider: StatusBarContentInsetsProvider + @Mock private lateinit var dumpManager: DumpManager + @Mock private lateinit var listener: SystemStatusAnimationCallback + + private lateinit var systemClock: FakeSystemClock + private lateinit var chipAnimationController: SystemEventChipAnimationController + private lateinit var systemStatusAnimationScheduler: SystemStatusAnimationScheduler + private val fakeFeatureFlags = FakeFeatureFlags() + + @get:Rule val animatorTestRule = AnimatorTestRule() + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + + fakeFeatureFlags.set(Flags.PLUG_IN_STATUS_BAR_CHIP, true) + + systemClock = FakeSystemClock() + chipAnimationController = + SystemEventChipAnimationController( + mContext, + statusBarWindowController, + statusBarContentInsetProvider, + fakeFeatureFlags + ) + + // ensure that isTooEarly() check in SystemStatusAnimationScheduler does not return true + systemClock.advanceTime(Process.getStartUptimeMillis() + MIN_UPTIME) + + // StatusBarContentInsetProvider is mocked. Ensure that it returns some mocked values. + whenever(statusBarContentInsetProvider.getStatusBarContentInsetsForCurrentRotation()) + .thenReturn(android.util.Pair(10, 10)) + whenever(statusBarContentInsetProvider.getStatusBarContentAreaForCurrentRotation()) + .thenReturn(Rect(10, 0, 990, 100)) + + // StatusBarWindowController is mocked. The addViewToWindow function needs to be mocked to + // ensure that the chip view is added to a parent view + whenever(statusBarWindowController.addViewToWindow(any(), any())).then { + val statusbarFake = FrameLayout(mContext) + statusbarFake.layout(0, 0, 1000, 100) + statusbarFake.addView( + it.arguments[0] as View, + it.arguments[1] as FrameLayout.LayoutParams + ) + } + } + + @Test + fun testBatteryStatusEvent_standardAnimationLifecycle() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + val batteryChip = createAndScheduleFakeBatteryEvent() + + // assert that animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // skip debounce delay + advanceTimeBy(DEBOUNCE_DELAY + 1) + // status chip starts animating in after debounce delay + assertEquals(ANIMATING_IN, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(0f, batteryChip.contentView.alpha) + assertEquals(0f, batteryChip.view.alpha) + verify(listener, times(1)).onSystemEventAnimationBegin() + + // skip appear animation + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + advanceTimeBy(APPEAR_ANIMATION_DURATION) + // assert that status chip is visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, batteryChip.contentView.alpha) + assertEquals(1f, batteryChip.view.alpha) + + // skip status chip display time + advanceTimeBy(DISPLAY_LENGTH + 1) + // assert that it is still visible but switched to the ANIMATING_OUT state + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, batteryChip.contentView.alpha) + assertEquals(1f, batteryChip.view.alpha) + verify(listener, times(1)).onSystemEventAnimationFinish(false) + + // skip disappear animation + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + // assert that it is not visible anymore + assertEquals(IDLE, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(0f, batteryChip.contentView.alpha) + assertEquals(0f, batteryChip.view.alpha) + } + + @Test + fun testPrivacyStatusEvent_standardAnimationLifecycle() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + val privacyChip = createAndScheduleFakePrivacyEvent() + + // assert that animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // skip debounce delay + advanceTimeBy(DEBOUNCE_DELAY + 1) + // status chip starts animating in after debounce delay + assertEquals(ANIMATING_IN, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(0f, privacyChip.view.alpha) + verify(listener, times(1)).onSystemEventAnimationBegin() + + // skip appear animation + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + advanceTimeBy(APPEAR_ANIMATION_DURATION + 1) + // assert that status chip is visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + + // skip status chip display time + advanceTimeBy(DISPLAY_LENGTH + 1) + // assert that it is still visible but switched to the ANIMATING_OUT state + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + verify(listener, times(1)).onSystemEventAnimationFinish(true) + verify(listener, times(1)).onSystemStatusAnimationTransitionToPersistentDot(any()) + + // skip transition to persistent dot + advanceTimeBy(DISAPPEAR_ANIMATION_DURATION + 1) + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + // assert that it the dot is now visible + assertEquals(SHOWING_PERSISTENT_DOT, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + + // notify SystemStatusAnimationScheduler to remove persistent dot + systemStatusAnimationScheduler.removePersistentDot() + // assert that IDLE state is entered + assertEquals(IDLE, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onHidePersistentDot() + } + + @Test + fun testHighPriorityEvent_takesPrecedenceOverScheduledLowPriorityEvent() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule low priority event + val batteryChip = createAndScheduleFakeBatteryEvent() + batteryChip.view.alpha = 0f + + // assert that animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // create and schedule high priority event + val privacyChip = createAndScheduleFakePrivacyEvent() + + // assert that animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // skip debounce delay and appear animation duration + fastForwardAnimationToState(RUNNING_CHIP_ANIM) + + // high priority status chip is visible while low priority status chip is not visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + assertEquals(0f, batteryChip.view.alpha) + } + + @Test + fun testHighPriorityEvent_cancelsCurrentlyDisplayedLowPriorityEvent() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule low priority event + val batteryChip = createAndScheduleFakeBatteryEvent() + + // fast forward to RUNNING_CHIP_ANIM state + fastForwardAnimationToState(RUNNING_CHIP_ANIM) + + // assert that chip is displayed + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, batteryChip.view.alpha) + + // create and schedule high priority event + val privacyChip = createAndScheduleFakePrivacyEvent() + + // ensure that the event cancellation coroutine is started by the test scope + testScheduler.runCurrent() + + // assert that currently displayed chip is immediately animated out + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + + // skip disappear animation + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + + // assert that high priority privacy chip animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // skip debounce delay and appear animation + advanceTimeBy(DEBOUNCE_DELAY + APPEAR_ANIMATION_DURATION + 1) + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + + // high priority status chip is visible while low priority status chip is not visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + assertEquals(0f, batteryChip.view.alpha) + } + + @Test + fun testHighPriorityEvent_cancelsCurrentlyAnimatedLowPriorityEvent() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule low priority event + val batteryChip = createAndScheduleFakeBatteryEvent() + + // skip debounce delay + advanceTimeBy(DEBOUNCE_DELAY + 1) + + // assert that chip is animated in + assertEquals(ANIMATING_IN, systemStatusAnimationScheduler.getAnimationState()) + + // create and schedule high priority event + val privacyChip = createAndScheduleFakePrivacyEvent() + + // ensure that the event cancellation coroutine is started by the test scope + testScheduler.runCurrent() + + // assert that currently animated chip keeps animating + assertEquals(ANIMATING_IN, systemStatusAnimationScheduler.getAnimationState()) + + // skip appear animation + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + advanceTimeBy(APPEAR_ANIMATION_DURATION + 1) + + // assert that low priority chip is animated out immediately after finishing the appear + // animation + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + + // skip disappear animation + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + + // assert that high priority privacy chip animation is queued + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + + // skip debounce delay and appear animation + advanceTimeBy(DEBOUNCE_DELAY + APPEAR_ANIMATION_DURATION + 1) + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + + // high priority status chip is visible while low priority status chip is not visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + assertEquals(0f, batteryChip.view.alpha) + } + + @Test + fun testHighPriorityEvent_isNotReplacedByLowPriorityEvent() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule high priority event + val privacyChip = createAndScheduleFakePrivacyEvent() + + // create and schedule low priority event + val batteryChip = createAndScheduleFakeBatteryEvent() + batteryChip.view.alpha = 0f + + // skip debounce delay and appear animation + advanceTimeBy(DEBOUNCE_DELAY + APPEAR_ANIMATION_DURATION + 1) + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + + // high priority status chip is visible while low priority status chip is not visible + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + assertEquals(1f, privacyChip.view.alpha) + assertEquals(0f, batteryChip.view.alpha) + } + + @Test + fun testPrivacyDot_isRemoved() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule high priority event + createAndScheduleFakePrivacyEvent() + + // skip chip animation lifecycle and fast forward to SHOWING_PERSISTENT_DOT state + fastForwardAnimationToState(SHOWING_PERSISTENT_DOT) + assertEquals(SHOWING_PERSISTENT_DOT, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onSystemStatusAnimationTransitionToPersistentDot(any()) + + // remove persistent dot and verify that animationState changes to IDLE + systemStatusAnimationScheduler.removePersistentDot() + assertEquals(IDLE, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onHidePersistentDot() + } + + @Test + fun testPrivacyDot_isRemovedDuringChipAnimation() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule high priority event + createAndScheduleFakePrivacyEvent() + + // skip chip animation lifecycle and fast forward to RUNNING_CHIP_ANIM state + fastForwardAnimationToState(RUNNING_CHIP_ANIM) + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + + // request removal of persistent dot + systemStatusAnimationScheduler.removePersistentDot() + + // skip display time and verify that disappear animation is run + advanceTimeBy(DISPLAY_LENGTH + 1) + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + + // skip disappear animation and verify that animationState changes to IDLE instead of + // SHOWING_PERSISTENT_DOT + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + assertEquals(IDLE, systemStatusAnimationScheduler.getAnimationState()) + // verify that the persistent dot callbacks are not invoked + verify(listener, never()).onSystemStatusAnimationTransitionToPersistentDot(any()) + verify(listener, never()).onHidePersistentDot() + } + + @Test + fun testNewEvent_isScheduled_whenPostedDuringRemovalAnimation() = runTest { + // Instantiate class under test with TestScope from runTest + initializeSystemStatusAnimationScheduler(testScope = this) + + // create and schedule high priority event + createAndScheduleFakePrivacyEvent() + + // skip chip animation lifecycle and fast forward to ANIMATING_OUT state + fastForwardAnimationToState(ANIMATING_OUT) + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onSystemStatusAnimationTransitionToPersistentDot(any()) + + // request removal of persistent dot + systemStatusAnimationScheduler.removePersistentDot() + testScheduler.runCurrent() + + // schedule another high priority event while the event is animating out + createAndScheduleFakePrivacyEvent() + + // verify that the state is still ANIMATING_OUT + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + + // skip disappear animation duration and verify that new state is ANIMATION_QUEUED + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + testScheduler.runCurrent() + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + // also verify that onHidePersistentDot callback is called + verify(listener, times(1)).onHidePersistentDot() + } + + private fun TestScope.fastForwardAnimationToState(@SystemAnimationState animationState: Int) { + // this function should only be called directly after posting a status event + assertEquals(ANIMATION_QUEUED, systemStatusAnimationScheduler.getAnimationState()) + if (animationState == IDLE || animationState == ANIMATION_QUEUED) return + // skip debounce delay + advanceTimeBy(DEBOUNCE_DELAY + 1) + + // status chip starts animating in after debounce delay + assertEquals(ANIMATING_IN, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onSystemEventAnimationBegin() + if (animationState == ANIMATING_IN) return + + // skip appear animation + animatorTestRule.advanceTimeBy(APPEAR_ANIMATION_DURATION) + advanceTimeBy(APPEAR_ANIMATION_DURATION) + assertEquals(RUNNING_CHIP_ANIM, systemStatusAnimationScheduler.getAnimationState()) + if (animationState == RUNNING_CHIP_ANIM) return + + // skip status chip display time + advanceTimeBy(DISPLAY_LENGTH + 1) + assertEquals(ANIMATING_OUT, systemStatusAnimationScheduler.getAnimationState()) + verify(listener, times(1)).onSystemEventAnimationFinish(anyBoolean()) + if (animationState == ANIMATING_OUT) return + + // skip disappear animation + animatorTestRule.advanceTimeBy(DISAPPEAR_ANIMATION_DURATION) + } + + private fun createAndScheduleFakePrivacyEvent(): OngoingPrivacyChip { + val privacyChip = OngoingPrivacyChip(mContext) + val fakePrivacyStatusEvent = + FakeStatusEvent(viewCreator = { privacyChip }, priority = 100, forceVisible = true) + systemStatusAnimationScheduler.onStatusEvent(fakePrivacyStatusEvent) + return privacyChip + } + + private fun createAndScheduleFakeBatteryEvent(): BatteryStatusChip { + val batteryChip = BatteryStatusChip(mContext) + val fakeBatteryEvent = + FakeStatusEvent(viewCreator = { batteryChip }, priority = 50, forceVisible = false) + systemStatusAnimationScheduler.onStatusEvent(fakeBatteryEvent) + return batteryChip + } + + private fun initializeSystemStatusAnimationScheduler(testScope: TestScope) { + systemStatusAnimationScheduler = + SystemStatusAnimationSchedulerImpl( + systemEventCoordinator, + chipAnimationController, + statusBarWindowController, + dumpManager, + systemClock, + CoroutineScope(StandardTestDispatcher(testScope.testScheduler)) + ) + // add a mock listener + systemStatusAnimationScheduler.addCallback(listener) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java index 07e8d3c195182..1e5782b91be8e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/fragment/CollapsedStatusBarFragmentTest.java @@ -32,7 +32,6 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import android.animation.Animator; import android.app.Fragment; import android.app.StatusBarManager; import android.content.Context; @@ -45,6 +44,7 @@ import android.view.View; import android.view.ViewPropertyAnimator; import android.widget.FrameLayout; +import androidx.core.animation.Animator; import androidx.test.filters.SmallTest; import com.android.keyguard.KeyguardUpdateMonitor;