1 /*
<lambda>null2  * 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                 announceForAccessibilityIfNeeded(event)
258                 delay(APPEAR_ANIMATION_DURATION + DISPLAY_LENGTH)
259                 runChipDisappearAnimation()
260             }
261     }
262 
263     private fun announceForAccessibilityIfNeeded(event: StatusEvent) {
264         val description = event.contentDescription ?: return
265         if (!event.shouldAnnounceAccessibilityEvent)  return
266         chipAnimationController.announceForAccessibility(description)
267     }
268 
269     /**
270      * 1. Define a total budget for the chip animation (1500ms)
271      * 2. Send out callbacks to listeners so that they can generate animations locally
272      * 3. Update the scheduler state so that clients know where we are
273      * 4. Maybe: provide scaffolding such as: dot location, margins, etc
274      * 5. Maybe: define a maximum animation length and enforce it. Probably only doable if we
275      *    collect all of the animators and run them together.
276      */
277     private fun runChipAppearAnimation() {
278         Assert.isMainThread()
279         if (hasPersistentDot) {
280             statusBarWindowController.setForceStatusBarVisible(true)
281         }
282         animationState.value = ANIMATING_IN
283 
284         val animSet = collectStartAnimations()
285         if (animSet.totalDuration > 500) {
286             throw IllegalStateException(
287                 "System animation total length exceeds budget. " +
288                     "Expected: 500, actual: ${animSet.totalDuration}"
289             )
290         }
291         animSet.addListener(
292             object : AnimatorListenerAdapter() {
293                 override fun onAnimationEnd(animation: Animator) {
294                     animationState.value = RUNNING_CHIP_ANIM
295                 }
296             }
297         )
298         animSet.start()
299     }
300 
301     private fun runChipDisappearAnimation() {
302         Assert.isMainThread()
303         val animSet2 = collectFinishAnimations()
304         animationState.value = ANIMATING_OUT
305         animSet2.addListener(
306             object : AnimatorListenerAdapter() {
307                 override fun onAnimationEnd(animation: Animator) {
308                     animationState.value =
309                         when {
310                             hasPersistentDot -> SHOWING_PERSISTENT_DOT
311                             scheduledEvent.value != null -> ANIMATION_QUEUED
312                             else -> IDLE
313                         }
314                     statusBarWindowController.setForceStatusBarVisible(false)
315                 }
316             }
317         )
318         animSet2.start()
319 
320         // currentlyDisplayedEvent is set to null before the animation has ended such that new
321         // events can be scheduled during the disappear animation. We don't want to miss e.g. a new
322         // privacy event being scheduled during the disappear animation, otherwise we could end up
323         // with e.g. an active microphone but no privacy dot being displayed.
324         currentlyDisplayedEvent = null
325     }
326 
327     private fun collectStartAnimations(): AnimatorSet {
328         val animators = mutableListOf<Animator>()
329         listeners.forEach { listener ->
330             listener.onSystemEventAnimationBegin()?.let { anim -> animators.add(anim) }
331         }
332         animators.add(chipAnimationController.onSystemEventAnimationBegin())
333 
334         return AnimatorSet().also { it.playTogether(animators) }
335     }
336 
337     private fun collectFinishAnimations(): AnimatorSet {
338         val animators = mutableListOf<Animator>()
339         listeners.forEach { listener ->
340             listener.onSystemEventAnimationFinish(hasPersistentDot)?.let { anim ->
341                 animators.add(anim)
342             }
343         }
344         animators.add(chipAnimationController.onSystemEventAnimationFinish(hasPersistentDot))
345         if (hasPersistentDot) {
346             val dotAnim = notifyTransitionToPersistentDot(currentlyDisplayedEvent)
347             if (dotAnim != null) {
348                 animators.add(dotAnim)
349             }
350         }
351 
352         return AnimatorSet().also { it.playTogether(animators) }
353     }
354 
355     private fun notifyTransitionToPersistentDot(event: StatusEvent?): Animator? {
356         logger?.logTransitionToPersistentDotCallbackInvoked()
357         val anims: List<Animator> =
358             listeners.mapNotNull {
359                 it.onSystemStatusAnimationTransitionToPersistentDot(
360                     event?.contentDescription
361                 )
362             }
363         if (anims.isNotEmpty()) {
364             val aSet = AnimatorSet()
365             aSet.playTogether(anims)
366             return aSet
367         }
368 
369         return null
370     }
371 
372     private fun notifyHidePersistentDot(): Animator? {
373         Assert.isMainThread()
374         logger?.logHidePersistentDotCallbackInvoked()
375         val anims: List<Animator> = listeners.mapNotNull { it.onHidePersistentDot() }
376 
377         if (anims.isNotEmpty()) {
378             val aSet = AnimatorSet()
379             aSet.playTogether(anims)
380             return aSet
381         }
382 
383         return null
384     }
385 
386     override fun addCallback(listener: SystemStatusAnimationCallback) {
387         Assert.isMainThread()
388 
389         if (listeners.isEmpty()) {
390             coordinator.startObserving()
391         }
392         listeners.add(listener)
393     }
394 
395     override fun removeCallback(listener: SystemStatusAnimationCallback) {
396         Assert.isMainThread()
397 
398         listeners.remove(listener)
399         if (listeners.isEmpty()) {
400             coordinator.stopObserving()
401         }
402     }
403 
404     override fun dump(pw: PrintWriter, args: Array<out String>) {
405         pw.println("Scheduled event: ${scheduledEvent.value}")
406         pw.println("Currently displayed event: $currentlyDisplayedEvent")
407         pw.println("Has persistent privacy dot: $hasPersistentDot")
408         pw.println("Animation state: ${animationState.value}")
409         pw.println("Listeners:")
410         if (listeners.isEmpty()) {
411             pw.println("(none)")
412         } else {
413             listeners.forEach { pw.println("  $it") }
414         }
415     }
416 }
417 
418 private const val TAG = "SystemStatusAnimationSchedulerImpl"
419