1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.statusbar.events
18 
19 import android.os.Process
20 import android.provider.DeviceConfig
21 import androidx.core.animation.Animator
22 import androidx.core.animation.AnimatorListenerAdapter
23 import androidx.core.animation.AnimatorSet
24 import com.android.systemui.dagger.qualifiers.Application
25 import com.android.systemui.dump.DumpManager
26 import com.android.systemui.statusbar.window.StatusBarWindowController
27 import com.android.systemui.util.Assert
28 import com.android.systemui.util.time.SystemClock
29 import java.io.PrintWriter
30 import javax.inject.Inject
31 import kotlinx.coroutines.CoroutineScope
32 import kotlinx.coroutines.FlowPreview
33 import kotlinx.coroutines.Job
34 import kotlinx.coroutines.delay
35 import kotlinx.coroutines.flow.MutableStateFlow
36 import kotlinx.coroutines.flow.combine
37 import kotlinx.coroutines.flow.debounce
38 import kotlinx.coroutines.flow.first
39 import kotlinx.coroutines.launch
40 import kotlinx.coroutines.withTimeout
41 
42 /**
43  * Scheduler for system status events. Obeys the following principles:
44  * ```
45  *      - Waits 100 ms to schedule any event for debouncing/prioritization
46  *      - Simple prioritization: Privacy > Battery > Connectivity (encoded in [StatusEvent])
47  *      - Only schedules a single event, and throws away lowest priority events
48  * ```
49  *
50  * There are 4 basic stages of animation at play here:
51  * ```
52  *      1. System chrome animation OUT
53  *      2. Chip animation IN
54  *      3. Chip animation OUT; potentially into a dot
55  *      4. System chrome animation IN
56  * ```
57  *
58  * Thus we can keep all animations synchronized with two separate ValueAnimators, one for system
59  * chrome and the other for the chip. These can animate from 0,1 and listeners can parameterize
60  * their respective views based on the progress of the animator.
61  */
62 @OptIn(FlowPreview::class)
63 open class SystemStatusAnimationSchedulerImpl
64 @Inject
65 constructor(
66     private val coordinator: SystemEventCoordinator,
67     private val chipAnimationController: SystemEventChipAnimationController,
68     private val statusBarWindowController: StatusBarWindowController,
69     dumpManager: DumpManager,
70     private val systemClock: SystemClock,
71     @Application private val coroutineScope: CoroutineScope,
72     private val logger: SystemStatusAnimationSchedulerLogger?
73 ) : SystemStatusAnimationScheduler {
74 
75     companion object {
76         private const val PROPERTY_ENABLE_IMMERSIVE_INDICATOR = "enable_immersive_indicator"
77     }
78 
79     /** Contains the StatusEvent that is going to be displayed next. */
80     private var scheduledEvent = MutableStateFlow<StatusEvent?>(null)
81 
82     /**
83      * The currently displayed status event. (This is null in all states except ANIMATING_IN and
84      * CHIP_ANIMATION_RUNNING)
85      */
86     private var currentlyDisplayedEvent: StatusEvent? = null
87 
88     /** StateFlow holding the current [SystemAnimationState] at any time. */
89     private var animationState = MutableStateFlow(IDLE)
90 
91     /** True if the persistent privacy dot should be active */
92     var hasPersistentDot = false
93         protected set
94 
95     /** Set of currently registered listeners */
96     protected val listeners = mutableSetOf<SystemStatusAnimationCallback>()
97 
98     /** The job that is controlling the animators of the currently displayed status event. */
99     private var currentlyRunningAnimationJob: Job? = null
100 
101     /** The job that is controlling the animators when an event is cancelled. */
102     private var eventCancellationJob: Job? = null
103 
104     init {
105         coordinator.attachScheduler(this)
106         dumpManager.registerCriticalDumpable(TAG, this)
107 
108         coroutineScope.launch {
109             // Wait for animationState to become ANIMATION_QUEUED and scheduledEvent to be non null.
110             // Once this combination is stable for at least DEBOUNCE_DELAY, then start a chip enter
111             // animation
112             animationState
113                 .combine(scheduledEvent) { animationState, scheduledEvent ->
114                     Pair(animationState, scheduledEvent)
115                 }
116                 .debounce(DEBOUNCE_DELAY)
117                 .collect { (animationState, event) ->
118                     if (animationState == ANIMATION_QUEUED && event != null) {
119                         startAnimationLifecycle(event)
120                         scheduledEvent.value = null
121                     }
122                 }
123         }
124 
125         coroutineScope.launch {
126             animationState.collect { logger?.logAnimationStateUpdate(it) }
127         }
128     }
129 
130     @SystemAnimationState override fun getAnimationState(): Int = animationState.value
131 
132     override fun onStatusEvent(event: StatusEvent) {
133         Assert.isMainThread()
134 
135         // Ignore any updates until the system is up and running. However, for important events that
136         // request to be force visible (like privacy), ignore whether it's too early.
137         if ((isTooEarly() && !event.forceVisible) || !isImmersiveIndicatorEnabled()) {
138             return
139         }
140 
141         if (
142             (event.priority > (scheduledEvent.value?.priority ?: -1)) &&
143                 (event.priority > (currentlyDisplayedEvent?.priority ?: -1)) &&
144                 !hasPersistentDot
145         ) {
146             // a event can only be scheduled if no other event is in progress or it has a higher
147             // priority. If a persistent dot is currently displayed, don't schedule the event.
148             logger?.logScheduleEvent(event)
149             scheduleEvent(event)
150         } else if (currentlyDisplayedEvent?.shouldUpdateFromEvent(event) == true) {
151             logger?.logUpdateEvent(event, animationState.value)
152             currentlyDisplayedEvent?.updateFromEvent(event)
153             if (event.forceVisible) hasPersistentDot = true
154         } else if (scheduledEvent.value?.shouldUpdateFromEvent(event) == true) {
155             logger?.logUpdateEvent(event, animationState.value)
156             scheduledEvent.value?.updateFromEvent(event)
157         } else {
158             logger?.logIgnoreEvent(event)
159         }
160     }
161 
162     override fun removePersistentDot() {
163         Assert.isMainThread()
164 
165         // If there is an event scheduled currently, set its forceVisible flag to false, such that
166         // it will never transform into a persistent dot
167         scheduledEvent.value?.forceVisible = false
168 
169         // Nothing else to do if hasPersistentDot is already false
170         if (!hasPersistentDot) return
171         // Set hasPersistentDot to false. If the animationState is anything before ANIMATING_OUT,
172         // the disappear animation will not animate into a dot but remove the chip entirely
173         hasPersistentDot = false
174 
175         if (animationState.value == SHOWING_PERSISTENT_DOT) {
176             // if we are currently showing a persistent dot, hide it and update the animationState
177             notifyHidePersistentDot()
178             if (scheduledEvent.value != null) {
179                 animationState.value = ANIMATION_QUEUED
180             } else {
181                 animationState.value = IDLE
182             }
183         } else if (animationState.value == ANIMATING_OUT) {
184             // if we are currently animating out, hide the dot. The animationState will be updated
185             // once the animation has ended in the onAnimationEnd callback
186             notifyHidePersistentDot()
187         }
188     }
189 
190     protected fun isTooEarly(): Boolean {
191         return systemClock.uptimeMillis() - Process.getStartUptimeMillis() < MIN_UPTIME
192     }
193 
194     protected fun isImmersiveIndicatorEnabled(): Boolean {
195         return DeviceConfig.getBoolean(
196             DeviceConfig.NAMESPACE_PRIVACY,
197             PROPERTY_ENABLE_IMMERSIVE_INDICATOR,
198             true
199         )
200     }
201 
202     /** Clear the scheduled event (if any) and schedule a new one */
203     private fun scheduleEvent(event: StatusEvent) {
204         scheduledEvent.value = event
205         if (currentlyDisplayedEvent != null && eventCancellationJob?.isActive != true) {
206             // cancel the currently displayed event. As soon as the event is animated out, the
207             // scheduled event will be displayed.
208             cancelCurrentlyDisplayedEvent()
209             return
210         }
211         if (animationState.value == IDLE) {
212             // If we are in IDLE state, set it to ANIMATION_QUEUED now
213             animationState.value = ANIMATION_QUEUED
214         }
215     }
216 
217     /**
218      * Cancels the currently displayed event by animating it out. This function should only be
219      * called if the animationState is ANIMATING_IN or RUNNING_CHIP_ANIM, or in other words whenever
220      * currentlyRunningEvent is not null
221      */
222     private fun cancelCurrentlyDisplayedEvent() {
223         eventCancellationJob =
224             coroutineScope.launch {
225                 withTimeout(APPEAR_ANIMATION_DURATION) {
226                     // wait for animationState to become RUNNING_CHIP_ANIM, then cancel the running
227                     // animation job and run the disappear animation immediately
228                     animationState.first { it == RUNNING_CHIP_ANIM }
229                     currentlyRunningAnimationJob?.cancel()
230                     runChipDisappearAnimation()
231                 }
232             }
233     }
234 
235     /**
236      * Takes the currently scheduled Event and (using the coroutineScope) animates it in and out
237      * again after displaying it for DISPLAY_LENGTH ms. This function should only be called if there
238      * is an event scheduled (and currentlyDisplayedEvent is null)
239      */
240     private fun startAnimationLifecycle(event: StatusEvent) {
241         Assert.isMainThread()
242         hasPersistentDot = event.forceVisible
243 
244         if (!event.showAnimation && event.forceVisible) {
245             // If animations are turned off, we'll transition directly to the dot
246             animationState.value = SHOWING_PERSISTENT_DOT
247             notifyTransitionToPersistentDot(event)
248             return
249         }
250 
251         currentlyDisplayedEvent = event
252 
253         chipAnimationController.prepareChipAnimation(event.viewCreator)
254         currentlyRunningAnimationJob =
255             coroutineScope.launch {
256                 runChipAppearAnimation()
257                 delay(APPEAR_ANIMATION_DURATION + DISPLAY_LENGTH)
258                 runChipDisappearAnimation()
259             }
260     }
261 
262     /**
263      * 1. Define a total budget for the chip animation (1500ms)
264      * 2. Send out callbacks to listeners so that they can generate animations locally
265      * 3. Update the scheduler state so that clients know where we are
266      * 4. Maybe: provide scaffolding such as: dot location, margins, etc
267      * 5. Maybe: define a maximum animation length and enforce it. Probably only doable if we
268      *    collect all of the animators and run them together.
269      */
270     private fun runChipAppearAnimation() {
271         Assert.isMainThread()
272         if (hasPersistentDot) {
273             statusBarWindowController.setForceStatusBarVisible(true)
274         }
275         animationState.value = ANIMATING_IN
276 
277         val animSet = collectStartAnimations()
278         if (animSet.totalDuration > 500) {
279             throw IllegalStateException(
280                 "System animation total length exceeds budget. " +
281                     "Expected: 500, actual: ${animSet.totalDuration}"
282             )
283         }
284         animSet.addListener(
285             object : AnimatorListenerAdapter() {
286                 override fun onAnimationEnd(animation: Animator) {
287                     animationState.value = RUNNING_CHIP_ANIM
288                 }
289             }
290         )
291         animSet.start()
292     }
293 
294     private fun runChipDisappearAnimation() {
295         Assert.isMainThread()
296         val animSet2 = collectFinishAnimations()
297         animationState.value = ANIMATING_OUT
298         animSet2.addListener(
299             object : AnimatorListenerAdapter() {
300                 override fun onAnimationEnd(animation: Animator) {
301                     animationState.value =
302                         when {
303                             hasPersistentDot -> SHOWING_PERSISTENT_DOT
304                             scheduledEvent.value != null -> ANIMATION_QUEUED
305                             else -> IDLE
306                         }
307                     statusBarWindowController.setForceStatusBarVisible(false)
308                 }
309             }
310         )
311         animSet2.start()
312 
313         // currentlyDisplayedEvent is set to null before the animation has ended such that new
314         // events can be scheduled during the disappear animation. We don't want to miss e.g. a new
315         // privacy event being scheduled during the disappear animation, otherwise we could end up
316         // with e.g. an active microphone but no privacy dot being displayed.
317         currentlyDisplayedEvent = null
318     }
319 
320     private fun collectStartAnimations(): AnimatorSet {
321         val animators = mutableListOf<Animator>()
322         listeners.forEach { listener ->
323             listener.onSystemEventAnimationBegin()?.let { anim -> animators.add(anim) }
324         }
325         animators.add(chipAnimationController.onSystemEventAnimationBegin())
326 
327         return AnimatorSet().also { it.playTogether(animators) }
328     }
329 
330     private fun collectFinishAnimations(): AnimatorSet {
331         val animators = mutableListOf<Animator>()
332         listeners.forEach { listener ->
333             listener.onSystemEventAnimationFinish(hasPersistentDot)?.let { anim ->
334                 animators.add(anim)
335             }
336         }
337         animators.add(chipAnimationController.onSystemEventAnimationFinish(hasPersistentDot))
338         if (hasPersistentDot) {
339             val dotAnim = notifyTransitionToPersistentDot(currentlyDisplayedEvent)
340             if (dotAnim != null) {
341                 animators.add(dotAnim)
342             }
343         }
344 
345         return AnimatorSet().also { it.playTogether(animators) }
346     }
347 
348     private fun notifyTransitionToPersistentDot(event: StatusEvent?): Animator? {
349         logger?.logTransitionToPersistentDotCallbackInvoked()
350         val anims: List<Animator> =
351             listeners.mapNotNull {
352                 it.onSystemStatusAnimationTransitionToPersistentDot(
353                     event?.contentDescription
354                 )
355             }
356         if (anims.isNotEmpty()) {
357             val aSet = AnimatorSet()
358             aSet.playTogether(anims)
359             return aSet
360         }
361 
362         return null
363     }
364 
365     private fun notifyHidePersistentDot(): Animator? {
366         Assert.isMainThread()
367         logger?.logHidePersistentDotCallbackInvoked()
368         val anims: List<Animator> = listeners.mapNotNull { it.onHidePersistentDot() }
369 
370         if (anims.isNotEmpty()) {
371             val aSet = AnimatorSet()
372             aSet.playTogether(anims)
373             return aSet
374         }
375 
376         return null
377     }
378 
379     override fun addCallback(listener: SystemStatusAnimationCallback) {
380         Assert.isMainThread()
381 
382         if (listeners.isEmpty()) {
383             coordinator.startObserving()
384         }
385         listeners.add(listener)
386     }
387 
388     override fun removeCallback(listener: SystemStatusAnimationCallback) {
389         Assert.isMainThread()
390 
391         listeners.remove(listener)
392         if (listeners.isEmpty()) {
393             coordinator.stopObserving()
394         }
395     }
396 
397     override fun dump(pw: PrintWriter, args: Array<out String>) {
398         pw.println("Scheduled event: ${scheduledEvent.value}")
399         pw.println("Currently displayed event: $currentlyDisplayedEvent")
400         pw.println("Has persistent privacy dot: $hasPersistentDot")
401         pw.println("Animation state: ${animationState.value}")
402         pw.println("Listeners:")
403         if (listeners.isEmpty()) {
404             pw.println("(none)")
405         } else {
406             listeners.forEach { pw.println("  $it") }
407         }
408     }
409 }
410 
411 private const val TAG = "SystemStatusAnimationSchedulerImpl"
412