• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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