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