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