• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 android.util.Log
22 import androidx.core.animation.Animator
23 import androidx.core.animation.AnimatorListenerAdapter
24 import androidx.core.animation.AnimatorSet
25 import com.android.systemui.dagger.qualifiers.Application
26 import com.android.systemui.dump.DumpManager
27 import com.android.systemui.statusbar.window.StatusBarWindowController
28 import com.android.systemui.util.Assert
29 import com.android.systemui.util.time.SystemClock
30 import java.io.PrintWriter
31 import javax.inject.Inject
32 import kotlinx.coroutines.CoroutineScope
33 import kotlinx.coroutines.FlowPreview
34 import kotlinx.coroutines.Job
35 import kotlinx.coroutines.delay
36 import kotlinx.coroutines.flow.MutableStateFlow
37 import kotlinx.coroutines.flow.combine
38 import kotlinx.coroutines.flow.debounce
39 import kotlinx.coroutines.flow.first
40 import kotlinx.coroutines.launch
41 import kotlinx.coroutines.withTimeout
42 
43 /**
44  * Scheduler for system status events. Obeys the following principles:
45  * ```
46  *      - Waits 100 ms to schedule any event for debouncing/prioritization
47  *      - Simple prioritization: Privacy > Battery > Connectivity (encoded in [StatusEvent])
48  *      - Only schedules a single event, and throws away lowest priority events
49  * ```
50  *
51  * There are 4 basic stages of animation at play here:
52  * ```
53  *      1. System chrome animation OUT
54  *      2. Chip animation IN
55  *      3. Chip animation OUT; potentially into a dot
56  *      4. System chrome animation IN
57  * ```
58  *
59  * Thus we can keep all animations synchronized with two separate ValueAnimators, one for system
60  * chrome and the other for the chip. These can animate from 0,1 and listeners can parameterize
61  * their respective views based on the progress of the animator.
62  */
63 @OptIn(FlowPreview::class)
64 open class SystemStatusAnimationSchedulerImpl
65 @Inject
66 constructor(
67     private val coordinator: SystemEventCoordinator,
68     private val chipAnimationController: SystemEventChipAnimationController,
69     private val statusBarWindowController: StatusBarWindowController,
70     dumpManager: DumpManager,
71     private val systemClock: SystemClock,
72     @Application private val coroutineScope: CoroutineScope
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 
126     @SystemAnimationState override fun getAnimationState(): Int = animationState.value
127 
128     override fun onStatusEvent(event: StatusEvent) {
129         Assert.isMainThread()
130 
131         // Ignore any updates until the system is up and running
132         if (isTooEarly() || !isImmersiveIndicatorEnabled()) {
133             return
134         }
135 
136         if (
137             (event.priority > (scheduledEvent.value?.priority ?: -1)) &&
138                 (event.priority > (currentlyDisplayedEvent?.priority ?: -1)) &&
139                 !hasPersistentDot
140         ) {
141             // a event can only be scheduled if no other event is in progress or it has a higher
142             // priority. If a persistent dot is currently displayed, don't schedule the event.
143             if (DEBUG) {
144                 Log.d(TAG, "scheduling event $event")
145             }
146 
147             scheduleEvent(event)
148         } else if (currentlyDisplayedEvent?.shouldUpdateFromEvent(event) == true) {
149             if (DEBUG) {
150                 Log.d(
151                     TAG,
152                     "updating current event from: $event. animationState=${animationState.value}"
153                 )
154             }
155             currentlyDisplayedEvent?.updateFromEvent(event)
156         } else if (scheduledEvent.value?.shouldUpdateFromEvent(event) == true) {
157             if (DEBUG) {
158                 Log.d(
159                     TAG,
160                     "updating scheduled event from: $event. animationState=${animationState.value}"
161                 )
162             }
163             scheduledEvent.value?.updateFromEvent(event)
164         } else {
165             if (DEBUG) {
166                 Log.d(TAG, "ignoring event $event")
167             }
168         }
169     }
170 
171     override fun removePersistentDot() {
172         Assert.isMainThread()
173 
174         // If there is an event scheduled currently, set its forceVisible flag to false, such that
175         // it will never transform into a persistent dot
176         scheduledEvent.value?.forceVisible = false
177 
178         // Nothing else to do if hasPersistentDot is already false
179         if (!hasPersistentDot) return
180         // Set hasPersistentDot to false. If the animationState is anything before ANIMATING_OUT,
181         // the disappear animation will not animate into a dot but remove the chip entirely
182         hasPersistentDot = false
183         // if we are currently showing a persistent dot, hide it
184         if (animationState.value == SHOWING_PERSISTENT_DOT) notifyHidePersistentDot()
185         // if we are currently animating into a dot, wait for the animation to finish and then hide
186         // the dot
187         if (animationState.value == ANIMATING_OUT) {
188             coroutineScope.launch {
189                 withTimeout(DISAPPEAR_ANIMATION_DURATION) {
190                     animationState.first { it == SHOWING_PERSISTENT_DOT || it == ANIMATION_QUEUED }
191                     notifyHidePersistentDot()
192                 }
193             }
194         }
195     }
196 
197     protected fun isTooEarly(): Boolean {
198         return systemClock.uptimeMillis() - Process.getStartUptimeMillis() < MIN_UPTIME
199     }
200 
201     protected fun isImmersiveIndicatorEnabled(): Boolean {
202         return DeviceConfig.getBoolean(
203             DeviceConfig.NAMESPACE_PRIVACY,
204             PROPERTY_ENABLE_IMMERSIVE_INDICATOR,
205             true
206         )
207     }
208 
209     /** Clear the scheduled event (if any) and schedule a new one */
210     private fun scheduleEvent(event: StatusEvent) {
211         scheduledEvent.value = event
212         if (currentlyDisplayedEvent != null && eventCancellationJob?.isActive != true) {
213             // cancel the currently displayed event. As soon as the event is animated out, the
214             // scheduled event will be displayed.
215             cancelCurrentlyDisplayedEvent()
216             return
217         }
218         if (animationState.value == IDLE) {
219             // If we are in IDLE state, set it to ANIMATION_QUEUED now
220             animationState.value = ANIMATION_QUEUED
221         }
222     }
223 
224     /**
225      * Cancels the currently displayed event by animating it out. This function should only be
226      * called if the animationState is ANIMATING_IN or RUNNING_CHIP_ANIM, or in other words whenever
227      * currentlyRunningEvent is not null
228      */
229     private fun cancelCurrentlyDisplayedEvent() {
230         eventCancellationJob =
231             coroutineScope.launch {
232                 withTimeout(APPEAR_ANIMATION_DURATION) {
233                     // wait for animationState to become RUNNING_CHIP_ANIM, then cancel the running
234                     // animation job and run the disappear animation immediately
235                     animationState.first { it == RUNNING_CHIP_ANIM }
236                     currentlyRunningAnimationJob?.cancel()
237                     runChipDisappearAnimation()
238                 }
239             }
240     }
241 
242     /**
243      * Takes the currently scheduled Event and (using the coroutineScope) animates it in and out
244      * again after displaying it for DISPLAY_LENGTH ms. This function should only be called if there
245      * is an event scheduled (and currentlyDisplayedEvent is null)
246      */
247     private fun startAnimationLifecycle(event: StatusEvent) {
248         Assert.isMainThread()
249         hasPersistentDot = event.forceVisible
250 
251         if (!event.showAnimation && event.forceVisible) {
252             // If animations are turned off, we'll transition directly to the dot
253             animationState.value = SHOWING_PERSISTENT_DOT
254             notifyTransitionToPersistentDot()
255             return
256         }
257 
258         currentlyDisplayedEvent = event
259 
260         chipAnimationController.prepareChipAnimation(event.viewCreator)
261         currentlyRunningAnimationJob =
262             coroutineScope.launch {
263                 runChipAppearAnimation()
264                 delay(APPEAR_ANIMATION_DURATION + DISPLAY_LENGTH)
265                 runChipDisappearAnimation()
266             }
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()
347             if (dotAnim != null) {
348                 animators.add(dotAnim)
349             }
350         }
351 
352         return AnimatorSet().also { it.playTogether(animators) }
353     }
354 
355     private fun notifyTransitionToPersistentDot(): Animator? {
356         val anims: List<Animator> =
357             listeners.mapNotNull {
358                 it.onSystemStatusAnimationTransitionToPersistentDot(
359                     currentlyDisplayedEvent?.contentDescription
360                 )
361             }
362         if (anims.isNotEmpty()) {
363             val aSet = AnimatorSet()
364             aSet.playTogether(anims)
365             return aSet
366         }
367 
368         return null
369     }
370 
371     private fun notifyHidePersistentDot(): Animator? {
372         Assert.isMainThread()
373         val anims: List<Animator> = listeners.mapNotNull { it.onHidePersistentDot() }
374 
375         if (animationState.value == SHOWING_PERSISTENT_DOT) {
376             if (scheduledEvent.value != null) {
377                 animationState.value = ANIMATION_QUEUED
378             } else {
379                 animationState.value = IDLE
380             }
381         }
382 
383         if (anims.isNotEmpty()) {
384             val aSet = AnimatorSet()
385             aSet.playTogether(anims)
386             return aSet
387         }
388 
389         return null
390     }
391 
392     override fun addCallback(listener: SystemStatusAnimationCallback) {
393         Assert.isMainThread()
394 
395         if (listeners.isEmpty()) {
396             coordinator.startObserving()
397         }
398         listeners.add(listener)
399     }
400 
401     override fun removeCallback(listener: SystemStatusAnimationCallback) {
402         Assert.isMainThread()
403 
404         listeners.remove(listener)
405         if (listeners.isEmpty()) {
406             coordinator.stopObserving()
407         }
408     }
409 
410     override fun dump(pw: PrintWriter, args: Array<out String>) {
411         pw.println("Scheduled event: ${scheduledEvent.value}")
412         pw.println("Currently displayed event: $currentlyDisplayedEvent")
413         pw.println("Has persistent privacy dot: $hasPersistentDot")
414         pw.println("Animation state: ${animationState.value}")
415         pw.println("Listeners:")
416         if (listeners.isEmpty()) {
417             pw.println("(none)")
418         } else {
419             listeners.forEach { pw.println("  $it") }
420         }
421     }
422 }
423 
424 private const val DEBUG = false
425 private const val TAG = "SystemStatusAnimationSchedulerImpl"
426