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