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