• 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.casttootherdevice.ui.viewmodel
18 
19 import android.content.Context
20 import androidx.annotation.DrawableRes
21 import com.android.internal.jank.Cuj
22 import com.android.systemui.animation.DialogCuj
23 import com.android.systemui.animation.DialogTransitionAnimator
24 import com.android.systemui.common.shared.model.ContentDescription
25 import com.android.systemui.common.shared.model.Icon
26 import com.android.systemui.dagger.SysUISingleton
27 import com.android.systemui.dagger.qualifiers.Application
28 import com.android.systemui.log.LogBuffer
29 import com.android.systemui.log.core.LogLevel
30 import com.android.systemui.res.R
31 import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad
32 import com.android.systemui.statusbar.chips.StatusBarChipsLog
33 import com.android.systemui.statusbar.chips.casttootherdevice.domain.interactor.MediaRouterChipInteractor
34 import com.android.systemui.statusbar.chips.casttootherdevice.domain.model.MediaRouterCastModel
35 import com.android.systemui.statusbar.chips.casttootherdevice.ui.view.EndCastScreenToOtherDeviceDialogDelegate
36 import com.android.systemui.statusbar.chips.casttootherdevice.ui.view.EndGenericCastToOtherDeviceDialogDelegate
37 import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractor
38 import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
39 import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper
40 import com.android.systemui.statusbar.chips.ui.model.ColorsModel
41 import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
42 import com.android.systemui.statusbar.chips.ui.viewmodel.ChipTransitionHelper
43 import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel
44 import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel.Companion.createDialogLaunchOnClickCallback
45 import com.android.systemui.statusbar.chips.ui.viewmodel.OngoingActivityChipViewModel.Companion.createDialogLaunchOnClickListener
46 import com.android.systemui.statusbar.chips.uievents.StatusBarChipsUiEventLogger
47 import com.android.systemui.util.time.SystemClock
48 import javax.inject.Inject
49 import kotlinx.coroutines.CoroutineScope
50 import kotlinx.coroutines.flow.SharingStarted
51 import kotlinx.coroutines.flow.StateFlow
52 import kotlinx.coroutines.flow.combine
53 import kotlinx.coroutines.flow.map
54 import kotlinx.coroutines.flow.stateIn
55 
56 /**
57  * View model for the cast-to-other-device chip, shown when a user is sharing content to a different
58  * device. (Triggered from the Quick Settings Cast tile or from the Settings app.) The content could
59  * either be the user's screen, or just the user's audio.
60  */
61 @SysUISingleton
62 class CastToOtherDeviceChipViewModel
63 @Inject
64 constructor(
65     @Application private val scope: CoroutineScope,
66     private val context: Context,
67     private val mediaProjectionChipInteractor: MediaProjectionChipInteractor,
68     private val mediaRouterChipInteractor: MediaRouterChipInteractor,
69     private val systemClock: SystemClock,
70     private val dialogTransitionAnimator: DialogTransitionAnimator,
71     private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper,
72     @StatusBarChipsLog private val logger: LogBuffer,
73     private val uiEventLogger: StatusBarChipsUiEventLogger,
74 ) : OngoingActivityChipViewModel {
75     // There can only be 1 active cast-to-other-device chip at a time, so we can re-use the ID.
76     private val instanceId = uiEventLogger.createNewInstanceId()
77 
78     /** The cast chip to show, based only on MediaProjection API events. */
79     private val projectionChip: StateFlow<OngoingActivityChipModel> =
80         mediaProjectionChipInteractor.projection
81             .map { projectionModel ->
82                 when (projectionModel) {
83                     is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Inactive()
84                     is ProjectionChipModel.Projecting -> {
85                         when (projectionModel.receiver) {
86                             ProjectionChipModel.Receiver.CastToOtherDevice -> {
87                                 when (projectionModel.contentType) {
88                                     ProjectionChipModel.ContentType.Screen ->
89                                         createCastScreenToOtherDeviceChip(projectionModel)
90                                     ProjectionChipModel.ContentType.Audio ->
91                                         createIconOnlyCastChip(deviceName = null)
92                                 }
93                             }
94                             ProjectionChipModel.Receiver.ShareToApp ->
95                                 OngoingActivityChipModel.Inactive()
96                         }
97                     }
98                 }
99             }
100             // See b/347726238 for [SharingStarted.Lazily] reasoning.
101             .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Inactive())
102 
103     /**
104      * The cast chip to show, based only on MediaRouter API events.
105      *
106      * This chip will be [OngoingActivityChipModel.Active] when the user is casting their screen
107      * *or* their audio.
108      *
109      * The MediaProjection APIs are typically not invoked for casting *only audio* to another device
110      * because MediaProjection is only concerned with *screen* sharing (see b/342169876). We listen
111      * to MediaRouter APIs here to cover audio-only casting.
112      *
113      * Note that this means we will start showing the cast chip before the casting actually starts,
114      * for **both** audio-only casting and screen casting. MediaRouter is aware of all
115      * cast-to-other-device events, and MediaRouter immediately marks a device as "connecting" once
116      * a user selects what device they'd like to cast to, even if they haven't hit "Start casting"
117      * yet. All of SysUI considers "connecting" devices to be casting (see
118      * [com.android.systemui.statusbar.policy.CastDevice.isCasting]), so the chip will follow the
119      * same convention and start showing once a device is selected. See b/269975671.
120      */
121     private val routerChip =
122         mediaRouterChipInteractor.mediaRouterCastingState
123             .map { routerModel ->
124                 when (routerModel) {
125                     is MediaRouterCastModel.DoingNothing -> OngoingActivityChipModel.Inactive()
126                     is MediaRouterCastModel.Casting -> {
127                         // A consequence of b/269975671 is that MediaRouter will mark a device as
128                         // casting before casting has actually started. To alleviate this bug a bit,
129                         // we won't show a timer for MediaRouter events. That way, we won't show a
130                         // timer if cast hasn't actually started.
131                         //
132                         // This does mean that the audio-only casting chip will *never* show a
133                         // timer, because audio-only casting never activates the MediaProjection
134                         // APIs and those are the only cast APIs that show a timer.
135                         createIconOnlyCastChip(routerModel.deviceName)
136                     }
137                 }
138             }
139             .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Inactive())
140 
141     private val internalChip: StateFlow<OngoingActivityChipModel> =
142         combine(projectionChip, routerChip) { projection, router ->
143                 logger.log(
144                     TAG,
145                     LogLevel.INFO,
146                     {
147                         str1 = projection.logName
148                         str2 = router.logName
149                     },
150                     { "projectionChip=$str1 > routerChip=$str2" },
151                 )
152 
153                 // A consequence of b/269975671 is that MediaRouter and MediaProjection APIs fire at
154                 // different times when *screen* casting:
155                 //
156                 // 1. When the user chooses what device to cast to, the MediaRouter APIs mark the
157                 // device as casting (even though casting hasn't actually started yet). At this
158                 // point, `routerChip` is [OngoingActivityChipModel.Active] but `projectionChip` is
159                 // [OngoingActivityChipModel.Inactive], and we'll show the router chip.
160                 //
161                 // 2. Once casting has actually started, the MediaProjection APIs become aware of
162                 // the device. At this point, both `routerChip` and `projectionChip` are
163                 // [OngoingActivityChipModel.Active].
164                 //
165                 // Because the MediaProjection APIs have activated, we know that the user is screen
166                 // casting (not audio casting). We need to switch to using `projectionChip` because
167                 // that chip will show information specific to screen casting. The `projectionChip`
168                 // will also show a timer, as opposed to `routerChip`'s icon-only display.
169                 if (projection is OngoingActivityChipModel.Active) {
170                     projection
171                 } else {
172                     router
173                 }
174             }
175             .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Inactive())
176 
177     private val hideChipDuringDialogTransitionHelper = ChipTransitionHelper(scope)
178 
179     override val chip: StateFlow<OngoingActivityChipModel> =
180         hideChipDuringDialogTransitionHelper.createChipFlow(internalChip)
181 
182     /** Stops the currently active projection. */
183     private fun stopProjectingFromDialog() {
184         logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested from dialog (projection)" })
185         hideChipDuringDialogTransitionHelper.onActivityStoppedFromDialog()
186         mediaProjectionChipInteractor.stopProjecting()
187     }
188 
189     /** Stops the currently active media route. */
190     private fun stopMediaRouterCastingFromDialog() {
191         logger.log(TAG, LogLevel.INFO, {}, { "Stop casting requested from dialog (router)" })
192         hideChipDuringDialogTransitionHelper.onActivityStoppedFromDialog()
193         mediaRouterChipInteractor.stopCasting()
194     }
195 
196     private fun createCastScreenToOtherDeviceChip(
197         state: ProjectionChipModel.Projecting
198     ): OngoingActivityChipModel.Active {
199         return OngoingActivityChipModel.Active.Timer(
200             key = KEY,
201             isImportantForPrivacy = true,
202             icon =
203                 OngoingActivityChipModel.ChipIcon.SingleColorIcon(
204                     Icon.Resource(
205                         CAST_TO_OTHER_DEVICE_ICON,
206                         // This string is "Casting screen"
207                         ContentDescription.Resource(
208                             R.string.cast_screen_to_other_device_chip_accessibility_label
209                         ),
210                     )
211                 ),
212             colors = ColorsModel.Red,
213             // TODO(b/332662551): Maybe use a MediaProjection API to fetch this time.
214             startTimeMs = systemClock.elapsedRealtime(),
215             onClickListenerLegacy =
216                 createDialogLaunchOnClickListener(
217                     createCastScreenToOtherDeviceDialogDelegate(state),
218                     dialogTransitionAnimator,
219                     DIALOG_CUJ,
220                     instanceId = instanceId,
221                     uiEventLogger = uiEventLogger,
222                     logger = logger,
223                     tag = TAG,
224                 ),
225             clickBehavior =
226                 OngoingActivityChipModel.ClickBehavior.ExpandAction(
227                     onClick =
228                         createDialogLaunchOnClickCallback(
229                             createCastScreenToOtherDeviceDialogDelegate(state),
230                             dialogTransitionAnimator,
231                             DIALOG_CUJ,
232                             instanceId = instanceId,
233                             uiEventLogger = uiEventLogger,
234                             logger = logger,
235                             tag = TAG,
236                         )
237                 ),
238             instanceId = instanceId,
239         )
240     }
241 
242     private fun createIconOnlyCastChip(deviceName: String?): OngoingActivityChipModel.Active {
243         return OngoingActivityChipModel.Active.IconOnly(
244             key = KEY,
245             isImportantForPrivacy = true,
246             icon =
247                 OngoingActivityChipModel.ChipIcon.SingleColorIcon(
248                     Icon.Resource(
249                         CAST_TO_OTHER_DEVICE_ICON,
250                         // This string is just "Casting"
251                         ContentDescription.Resource(R.string.accessibility_casting),
252                     )
253                 ),
254             colors = ColorsModel.Red,
255             onClickListenerLegacy =
256                 createDialogLaunchOnClickListener(
257                     createGenericCastToOtherDeviceDialogDelegate(deviceName),
258                     dialogTransitionAnimator,
259                     DIALOG_CUJ_AUDIO_ONLY,
260                     instanceId = instanceId,
261                     uiEventLogger = uiEventLogger,
262                     logger = logger,
263                     tag = TAG,
264                 ),
265             clickBehavior =
266                 OngoingActivityChipModel.ClickBehavior.ExpandAction(
267                     createDialogLaunchOnClickCallback(
268                         createGenericCastToOtherDeviceDialogDelegate(deviceName),
269                         dialogTransitionAnimator,
270                         DIALOG_CUJ_AUDIO_ONLY,
271                         instanceId = instanceId,
272                         uiEventLogger = uiEventLogger,
273                         logger = logger,
274                         tag = TAG,
275                     )
276                 ),
277             instanceId = instanceId,
278         )
279     }
280 
281     private fun createCastScreenToOtherDeviceDialogDelegate(state: ProjectionChipModel.Projecting) =
282         EndCastScreenToOtherDeviceDialogDelegate(
283             endMediaProjectionDialogHelper,
284             context,
285             stopAction = this::stopProjectingFromDialog,
286             state,
287         )
288 
289     private fun createGenericCastToOtherDeviceDialogDelegate(deviceName: String?) =
290         EndGenericCastToOtherDeviceDialogDelegate(
291             endMediaProjectionDialogHelper,
292             context,
293             deviceName,
294             stopAction = this::stopMediaRouterCastingFromDialog,
295         )
296 
297     companion object {
298         const val KEY = "CastToOtherDevice"
299         @DrawableRes val CAST_TO_OTHER_DEVICE_ICON = R.drawable.ic_cast_connected
300         private val DIALOG_CUJ =
301             DialogCuj(Cuj.CUJ_STATUS_BAR_LAUNCH_DIALOG_FROM_CHIP, tag = "Cast to other device")
302         private val DIALOG_CUJ_AUDIO_ONLY =
303             DialogCuj(
304                 Cuj.CUJ_STATUS_BAR_LAUNCH_DIALOG_FROM_CHIP,
305                 tag = "Cast to other device audio only",
306             )
307         private val TAG = "CastToOtherVM".pad()
308     }
309 }
310