diff options
10 files changed, 876 insertions, 325 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java index 03a1dc068d3d..378b7bb618f8 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 5b4ce065791d..f0ee44305b10 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/statusbar/events/StatusBarEventsModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusBarEventsModule.kt new file mode 100644 index 000000000000..3d6d48917dd3 --- /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 fd057a543c55..43f78c3166e4 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt @@ -30,7 +30,7 @@ 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 @@ -72,7 +72,7 @@ class BGImageView( 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? = "" @@ -90,7 +90,7 @@ class BatteryEvent(@IntRange(from = 0, to = 100) val batteryLevel: Int) : Status 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<PrivacyItem> = listOf() private var privacyChip: OngoingPrivacyChip? = null 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 52def065ecc2..776956a20140 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventChipAnimationController.kt @@ -31,6 +31,8 @@ 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 - } - - // 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 + 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() + 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) } } @@ -261,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 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 225ced5f1058..26fd2307c59d 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt @@ -66,7 +66,7 @@ class SystemEventCoordinator @Inject constructor( } fun notifyPrivacyItemsEmpty() { - scheduler.setShouldShowPersistentPrivacyIndicator(false) + scheduler.removePersistentDot() } fun notifyPrivacyItemsChanged(showAnimation: Boolean = true) { 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 13a70d6e22b5..2a18f1f51ace 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,298 +16,21 @@ package com.android.systemui.statusbar.events +import android.annotation.IntDef import androidx.core.animation.Animator import androidx.core.animation.AnimatorSet -import android.annotation.IntDef -import android.os.Process -import android.provider.DeviceConfig -import android.util.Log -import androidx.core.animation.AnimatorListenerAdapter 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<SystemStatusAnimationCallback>, Dumpable { - - 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 var animationState: Int = IDLE - private set - - /** True if the persistent privacy dot should be active */ - var hasPersistentDot = false - protected set - - private var scheduledEvent: StatusEvent? = null - private var cancelExecutionRunnable: Runnable? = null - val listeners = mutableSetOf<SystemStatusAnimationCallback>() - - 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 - } +interface SystemStatusAnimationScheduler : + CallbackController<SystemStatusAnimationCallback>, Dumpable { - // 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") - } - } - } - - private fun clearDotIfVisible() { - notifyHidePersistentDot() - } - - fun setShouldShowPersistentPrivacyIndicator(should: Boolean) { - if (hasPersistentDot == should || !isImmersiveIndicatorEnabled()) { - return - } - - hasPersistentDot = should - - if (!hasPersistentDot) { - clearDotIfVisible() - } - } - - 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) - } + @SystemAnimationState fun getAnimationState(): Int - /** - * 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 + fun onStatusEvent(event: StatusEvent) - 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<Animator>() - 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<Animator>() - 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<Animator> = 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<Animator> = 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<out String>) { - 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() } /** @@ -333,6 +56,7 @@ interface SystemStatusAnimationCallback { @JvmDefault fun onHidePersistentDot(): Animator? { return null } } + /** * Animation state IntDef */ @@ -350,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 */ @@ -375,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 - -private const val MIN_UPTIME: Long = 5 * 1000 +internal const val APPEAR_ANIMATION_DURATION = 500L +internal const val DISPLAY_LENGTH = 3000L +internal const val DISAPPEAR_ANIMATION_DURATION = 500L -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 000000000000..f7a4feafee25 --- /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<StatusEvent?>(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<SystemStatusAnimationCallback>() + + /** 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<Animator>() + 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<Animator>() + 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<Animator> = + 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<Animator> = 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<out String>) { + 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 000000000000..64b7ac9ee0a1 --- /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<SystemStatusAnimationCallback>() + + 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<Animator>() + 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<Animator>() + 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<Animator> = + 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<Animator> = 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<out String>) { + 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/tv/TvSystemUIModule.java b/packages/SystemUI/src/com/android/systemui/tv/TvSystemUIModule.java index 82200c61eeb5..360fc90a2d24 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, } ) |