1 /* <lambda>null2 * Copyright (C) 2024 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.chips.call.ui.viewmodel 18 19 import android.app.PendingIntent 20 import android.content.ComponentName 21 import android.content.Context 22 import android.view.View 23 import com.android.internal.jank.Cuj 24 import com.android.internal.logging.InstanceId 25 import com.android.systemui.animation.ActivityTransitionAnimator 26 import com.android.systemui.animation.ComposableControllerFactory 27 import com.android.systemui.animation.DelegateTransitionAnimatorController 28 import com.android.systemui.common.shared.model.ContentDescription 29 import com.android.systemui.common.shared.model.Icon 30 import com.android.systemui.dagger.SysUISingleton 31 import com.android.systemui.dagger.qualifiers.Application 32 import com.android.systemui.dagger.qualifiers.Main 33 import com.android.systemui.log.LogBuffer 34 import com.android.systemui.log.core.Logger 35 import com.android.systemui.plugins.ActivityStarter 36 import com.android.systemui.res.R 37 import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad 38 import com.android.systemui.statusbar.chips.StatusBarChipsLog 39 import com.android.systemui.statusbar.chips.StatusBarChipsReturnAnimations 40 import com.android.systemui.statusbar.chips.call.domain.interactor.CallChipInteractor 41 import com.android.systemui.statusbar.chips.ui.model.ColorsModel 42 import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel 43 import com.android.systemui.statusbar.chips.ui.view.ChipBackgroundContainer 44 import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel 45 import com.android.systemui.statusbar.chips.uievents.StatusBarChipsUiEventLogger 46 import com.android.systemui.statusbar.core.StatusBarConnectedDisplays 47 import com.android.systemui.statusbar.phone.ongoingcall.StatusBarChipsModernization 48 import com.android.systemui.statusbar.phone.ongoingcall.shared.model.OngoingCallModel 49 import com.android.systemui.util.time.SystemClock 50 import javax.inject.Inject 51 import kotlinx.coroutines.CoroutineScope 52 import kotlinx.coroutines.flow.MutableStateFlow 53 import kotlinx.coroutines.flow.SharingStarted 54 import kotlinx.coroutines.flow.StateFlow 55 import kotlinx.coroutines.flow.asStateFlow 56 import kotlinx.coroutines.flow.combine 57 import kotlinx.coroutines.flow.first 58 import kotlinx.coroutines.flow.map 59 import kotlinx.coroutines.flow.mapNotNull 60 import kotlinx.coroutines.flow.stateIn 61 62 /** View model for the ongoing phone call chip shown in the status bar. */ 63 @SysUISingleton 64 class CallChipViewModel 65 @Inject 66 constructor( 67 @Main private val context: Context, 68 @Application private val scope: CoroutineScope, 69 interactor: CallChipInteractor, 70 systemClock: SystemClock, 71 private val activityStarter: ActivityStarter, 72 @StatusBarChipsLog private val logBuffer: LogBuffer, 73 private val uiEventLogger: StatusBarChipsUiEventLogger, 74 ) : OngoingActivityChipViewModel { 75 private val logger = Logger(logBuffer, "OngoingCallVM".pad()) 76 /** The transition cookie used to register and unregister launch and return animations. */ 77 private val cookie = 78 ActivityTransitionAnimator.TransitionCookie("${CallChipViewModel::class.java}") 79 80 /** 81 * Used internally to determine when a launch or return animation is in progress, as these 82 * require special handling. 83 */ 84 private val transitionState: MutableStateFlow<TransitionState> = 85 MutableStateFlow(TransitionState.NoTransition) 86 87 // Since we're combining the chip state and the transition state flows, getting the old value by 88 // using [pairwise()] would confuse things. This is because if the calculation is triggered by 89 // a change in transition state, the chip state will still show the previous and current values, 90 // making it difficult to figure out what actually changed. Instead we cache the old value here, 91 // so that at each update we can keep track of what actually changed. 92 private var latestState: OngoingCallModel = OngoingCallModel.NoCall 93 private var latestTransitionState: TransitionState = TransitionState.NoTransition 94 95 private val chipWithReturnAnimation: StateFlow<OngoingActivityChipModel> = 96 if (StatusBarChipsReturnAnimations.isEnabled) { 97 combine(interactor.ongoingCallState, transitionState) { newState, newTransitionState -> 98 val oldState = latestState 99 latestState = newState 100 val oldTransitionState = latestTransitionState 101 latestTransitionState = newTransitionState 102 103 logger.d({ 104 "Call chip state updated: $str1" + 105 " oldTransitionState=$str2" + 106 " newTransitionState=$str3" 107 }) { 108 str1 = "oldState=${oldState.logString()} newState=${newState.logString()}" 109 str2 = oldTransitionState::class.simpleName 110 str3 = newTransitionState::class.simpleName 111 } 112 113 when (newState) { 114 is OngoingCallModel.NoCall -> 115 OngoingActivityChipModel.Inactive( 116 transitionManager = getTransitionManager(newState) 117 ) 118 119 is OngoingCallModel.InCall -> 120 prepareChip( 121 newState, 122 systemClock, 123 isHidden = 124 shouldChipBeHidden( 125 oldState = oldState, 126 newState = newState, 127 oldTransitionState = oldTransitionState, 128 newTransitionState = newTransitionState, 129 ), 130 transitionState = newTransitionState, 131 ) 132 } 133 } 134 .stateIn( 135 scope, 136 SharingStarted.WhileSubscribed(), 137 OngoingActivityChipModel.Inactive(), 138 ) 139 } else { 140 MutableStateFlow(OngoingActivityChipModel.Inactive()).asStateFlow() 141 } 142 143 private val chipLegacy: StateFlow<OngoingActivityChipModel> = 144 if (!StatusBarChipsReturnAnimations.isEnabled) { 145 interactor.ongoingCallState 146 .map { state -> 147 logger.d({ "Call chip state updated: newState=$str1" }) { 148 str1 = state.logString() 149 } 150 151 when (state) { 152 is OngoingCallModel.NoCall -> OngoingActivityChipModel.Inactive() 153 is OngoingCallModel.InCall -> 154 if (state.isAppVisible) { 155 OngoingActivityChipModel.Inactive() 156 } else { 157 prepareChip(state, systemClock, isHidden = false) 158 } 159 } 160 } 161 .stateIn( 162 scope, 163 SharingStarted.WhileSubscribed(), 164 OngoingActivityChipModel.Inactive(), 165 ) 166 } else { 167 MutableStateFlow(OngoingActivityChipModel.Inactive()).asStateFlow() 168 } 169 170 override val chip: StateFlow<OngoingActivityChipModel> = 171 if (StatusBarChipsReturnAnimations.isEnabled) { 172 chipWithReturnAnimation 173 } else { 174 chipLegacy 175 } 176 177 /** 178 * The controller factory that the call chip uses to register and unregister its transition 179 * animations. 180 */ 181 private var transitionControllerFactory: ComposableControllerFactory? = null 182 183 /** Builds an [OngoingActivityChipModel.Active] from all the relevant information. */ 184 private fun prepareChip( 185 state: OngoingCallModel.InCall, 186 systemClock: SystemClock, 187 isHidden: Boolean, 188 transitionState: TransitionState = TransitionState.NoTransition, 189 ): OngoingActivityChipModel.Active { 190 val key = "$KEY_PREFIX${state.notificationKey}" 191 val contentDescription = getContentDescription(state.appName) 192 val icon = 193 if (state.notificationIconView != null) { 194 StatusBarConnectedDisplays.assertInLegacyMode() 195 OngoingActivityChipModel.ChipIcon.StatusBarView( 196 state.notificationIconView, 197 contentDescription, 198 ) 199 } else if (StatusBarConnectedDisplays.isEnabled) { 200 OngoingActivityChipModel.ChipIcon.StatusBarNotificationIcon( 201 state.notificationKey, 202 contentDescription, 203 ) 204 } else { 205 OngoingActivityChipModel.ChipIcon.SingleColorIcon(phoneIcon) 206 } 207 208 val colors = ColorsModel.AccentThemed 209 val intent = state.intent 210 val instanceId = state.notificationInstanceId 211 212 // This block mimics OngoingCallController#updateChip. 213 if (state.startTimeMs <= 0L) { 214 // If the start time is invalid, don't show a timer and show just an icon. 215 // See b/192379214. 216 return OngoingActivityChipModel.Active.IconOnly( 217 key = key, 218 icon = icon, 219 colors = colors, 220 onClickListenerLegacy = getOnClickListener(intent, instanceId), 221 clickBehavior = getClickBehavior(intent, instanceId), 222 isHidden = isHidden, 223 transitionManager = getTransitionManager(state, transitionState), 224 instanceId = instanceId, 225 ) 226 } else { 227 val startTimeInElapsedRealtime = 228 state.startTimeMs - systemClock.currentTimeMillis() + systemClock.elapsedRealtime() 229 return OngoingActivityChipModel.Active.Timer( 230 key = key, 231 icon = icon, 232 colors = colors, 233 startTimeMs = startTimeInElapsedRealtime, 234 onClickListenerLegacy = getOnClickListener(intent, instanceId), 235 clickBehavior = getClickBehavior(intent, instanceId), 236 isHidden = isHidden, 237 transitionManager = getTransitionManager(state, transitionState), 238 instanceId = instanceId, 239 ) 240 } 241 } 242 243 private fun getOnClickListener( 244 intent: PendingIntent?, 245 instanceId: InstanceId?, 246 ): View.OnClickListener? { 247 if (intent == null) return null 248 return View.OnClickListener { view -> 249 StatusBarChipsModernization.assertInLegacyMode() 250 251 logger.i({ "Chip clicked" }) {} 252 uiEventLogger.logChipTapToShow(instanceId) 253 254 val backgroundView = 255 view.requireViewById<ChipBackgroundContainer>(R.id.ongoing_activity_chip_background) 256 // This mimics OngoingCallController#updateChipClickListener. 257 activityStarter.postStartActivityDismissingKeyguard( 258 intent, 259 ActivityTransitionAnimator.Controller.fromView( 260 backgroundView, 261 Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP, 262 ), 263 ) 264 } 265 } 266 267 private fun getClickBehavior( 268 intent: PendingIntent?, 269 instanceId: InstanceId?, 270 ): OngoingActivityChipModel.ClickBehavior = 271 if (intent == null) { 272 OngoingActivityChipModel.ClickBehavior.None 273 } else { 274 OngoingActivityChipModel.ClickBehavior.ExpandAction( 275 onClick = { expandable -> 276 StatusBarChipsModernization.unsafeAssertInNewMode() 277 278 logger.i({ "Chip clicked" }) {} 279 uiEventLogger.logChipTapToShow(instanceId) 280 281 val animationController = 282 if ( 283 !StatusBarChipsReturnAnimations.isEnabled || 284 transitionControllerFactory == null 285 ) { 286 expandable.activityTransitionController( 287 Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP 288 ) 289 } else { 290 // When return animations are enabled, we use a long-lived registration 291 // with controllers created on-demand by the animation library instead 292 // of explicitly creating one at the time of the click. By not passing 293 // a controller here, we let the framework do its work. Otherwise, the 294 // explicit controller would take precedence and override the other one. 295 null 296 } 297 activityStarter.postStartActivityDismissingKeyguard(intent, animationController) 298 } 299 ) 300 } 301 302 private fun getContentDescription(appName: String): ContentDescription { 303 val ongoingCallDescription = context.getString(R.string.ongoing_call_content_description) 304 return ContentDescription.Loaded( 305 context.getString( 306 R.string.accessibility_desc_notification_icon, 307 appName, 308 ongoingCallDescription, 309 ) 310 ) 311 } 312 313 private fun getTransitionManager( 314 state: OngoingCallModel, 315 transitionState: TransitionState = TransitionState.NoTransition, 316 ): OngoingActivityChipModel.TransitionManager? { 317 if (!StatusBarChipsReturnAnimations.isEnabled) return null 318 return if (state is OngoingCallModel.NoCall) { 319 OngoingActivityChipModel.TransitionManager( 320 unregisterTransition = { activityStarter.unregisterTransition(cookie) } 321 ) 322 } else { 323 val component = (state as OngoingCallModel.InCall).intent?.intent?.component 324 if (component != null) { 325 val factory = getTransitionControllerFactory(component) 326 OngoingActivityChipModel.TransitionManager( 327 factory, 328 registerTransition = { 329 activityStarter.registerTransition(cookie, factory, scope) 330 }, 331 // Make the chip invisible at the beginning of the return transition to avoid 332 // it flickering. 333 hideChipForTransition = transitionState is TransitionState.ReturnRequested, 334 ) 335 } else { 336 // Without a component we can't instantiate a controller factory, and without a 337 // factory registering an animation is impossible. In this case, the transition 338 // manager is empty and inert. 339 OngoingActivityChipModel.TransitionManager() 340 } 341 } 342 } 343 344 private fun getTransitionControllerFactory( 345 component: ComponentName 346 ): ComposableControllerFactory { 347 var factory = transitionControllerFactory 348 if (factory?.component == component) return factory 349 350 factory = 351 object : 352 ComposableControllerFactory( 353 cookie, 354 component, 355 launchCujType = Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP, 356 returnCujType = Cuj.CUJ_STATUS_BAR_APP_RETURN_TO_CALL_CHIP, 357 ) { 358 override suspend fun createController( 359 forLaunch: Boolean 360 ): ActivityTransitionAnimator.Controller { 361 transitionState.value = 362 if (forLaunch) { 363 TransitionState.LaunchRequested 364 } else { 365 TransitionState.ReturnRequested 366 } 367 368 val controller = 369 expandable 370 .mapNotNull { 371 it?.activityTransitionController( 372 launchCujType, 373 cookie, 374 component, 375 returnCujType, 376 isEphemeral = false, 377 ) 378 } 379 .first() 380 381 return object : DelegateTransitionAnimatorController(controller) { 382 override val isLaunching: Boolean 383 get() = forLaunch 384 385 override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) { 386 delegate.onTransitionAnimationStart(isExpandingFullyAbove) 387 transitionState.value = 388 if (isLaunching) { 389 TransitionState.Launching 390 } else { 391 TransitionState.Returning 392 } 393 } 394 395 override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { 396 delegate.onTransitionAnimationEnd(isExpandingFullyAbove) 397 transitionState.value = TransitionState.NoTransition 398 } 399 400 override fun onTransitionAnimationCancelled( 401 newKeyguardOccludedState: Boolean? 402 ) { 403 delegate.onTransitionAnimationCancelled(newKeyguardOccludedState) 404 transitionState.value = TransitionState.NoTransition 405 } 406 } 407 } 408 } 409 410 transitionControllerFactory = factory 411 return factory 412 } 413 414 /** Define the current state of this chip's transition animation. */ 415 private sealed interface TransitionState { 416 /** Idle. */ 417 data object NoTransition : TransitionState 418 419 /** Launch animation has been requested but hasn't started yet. */ 420 data object LaunchRequested : TransitionState 421 422 /** Launch animation in progress. */ 423 data object Launching : TransitionState 424 425 /** Return animation has been requested but hasn't started yet. */ 426 data object ReturnRequested : TransitionState 427 428 /** Return animation in progress. */ 429 data object Returning : TransitionState 430 } 431 432 companion object { 433 private val phoneIcon = 434 Icon.Resource( 435 com.android.internal.R.drawable.ic_phone, 436 ContentDescription.Resource(R.string.ongoing_call_content_description), 437 ) 438 439 const val KEY_PREFIX = "callChip-" 440 441 /** Determines whether or not an active call chip should be hidden. */ 442 private fun shouldChipBeHidden( 443 oldState: OngoingCallModel, 444 newState: OngoingCallModel.InCall, 445 oldTransitionState: TransitionState, 446 newTransitionState: TransitionState, 447 ): Boolean { 448 // The app is in the background and no transitions are ongoing (during transitions, 449 // [isAppVisible] must always be true). Show the chip. 450 if (!newState.isAppVisible) return false 451 452 // The call has just started and is visible. Hide the chip. 453 if (oldState is OngoingCallModel.NoCall) return true 454 455 // The state went from the app not being visible to visible. This happens when the chip 456 // is tapped and a launch animation is about to start. Keep the chip showing. 457 if (!(oldState as OngoingCallModel.InCall).isAppVisible) return false 458 459 // The app was and remains visible, but the transition state has changed. A launch or 460 // return animation has been requested or is ongoing. Keep the chip showing. 461 if ( 462 newTransitionState is TransitionState.LaunchRequested || 463 newTransitionState is TransitionState.Launching || 464 newTransitionState is TransitionState.ReturnRequested || 465 newTransitionState is TransitionState.Returning 466 ) { 467 return false 468 } 469 470 // The app was and remains visible, so we generally want to hide the chip. The only 471 // exception is if a return transition has just ended. In this case, the transition 472 // state changes shortly before the app visibility does. If we hide the chip between 473 // these two updates, this results in a flicker. We bridge the gap by keeping the chip 474 // showing. 475 return oldTransitionState != TransitionState.Returning 476 } 477 } 478 } 479