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.sharetoapp.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.CoreStartable 23 import com.android.systemui.animation.DialogCuj 24 import com.android.systemui.animation.DialogTransitionAnimator 25 import com.android.systemui.common.shared.model.ContentDescription 26 import com.android.systemui.common.shared.model.Icon 27 import com.android.systemui.dagger.SysUISingleton 28 import com.android.systemui.dagger.qualifiers.Application 29 import com.android.systemui.log.LogBuffer 30 import com.android.systemui.log.core.LogLevel 31 import com.android.systemui.res.R 32 import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad 33 import com.android.systemui.statusbar.chips.StatusBarChipsLog 34 import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractor 35 import com.android.systemui.statusbar.chips.mediaprojection.domain.model.MediaProjectionStopDialogModel 36 import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel 37 import com.android.systemui.statusbar.chips.mediaprojection.ui.view.EndMediaProjectionDialogHelper 38 import com.android.systemui.statusbar.chips.sharetoapp.ui.view.EndGenericShareToAppDialogDelegate 39 import com.android.systemui.statusbar.chips.sharetoapp.ui.view.EndShareScreenToAppDialogDelegate 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.kotlin.sample 48 import com.android.systemui.util.time.SystemClock 49 import javax.inject.Inject 50 import kotlinx.coroutines.CoroutineScope 51 import kotlinx.coroutines.flow.MutableStateFlow 52 import kotlinx.coroutines.flow.SharingStarted 53 import kotlinx.coroutines.flow.StateFlow 54 import kotlinx.coroutines.flow.asStateFlow 55 import kotlinx.coroutines.flow.combine 56 import kotlinx.coroutines.flow.map 57 import kotlinx.coroutines.flow.stateIn 58 import kotlinx.coroutines.launch 59 60 /** 61 * View model for the share-to-app chip, shown when sharing your phone screen content to another app 62 * on the same device. (Triggered from within each individual app.) 63 */ 64 @SysUISingleton 65 class ShareToAppChipViewModel 66 @Inject 67 constructor( 68 @Application private val scope: CoroutineScope, 69 private val context: Context, 70 private val mediaProjectionChipInteractor: MediaProjectionChipInteractor, 71 private val systemClock: SystemClock, 72 private val endMediaProjectionDialogHelper: EndMediaProjectionDialogHelper, 73 private val dialogTransitionAnimator: DialogTransitionAnimator, 74 @StatusBarChipsLog private val logger: LogBuffer, 75 private val uiEventLogger: StatusBarChipsUiEventLogger, 76 ) : OngoingActivityChipViewModel, CoreStartable { 77 // There can only be 1 active cast-to-other-device chip at a time, so we can re-use the ID. 78 private val instanceId = uiEventLogger.createNewInstanceId() 79 80 private val _stopDialogToShow: MutableStateFlow<MediaProjectionStopDialogModel> = 81 MutableStateFlow(MediaProjectionStopDialogModel.Hidden) 82 83 /** 84 * Represents the current state of the media projection stop dialog. Emits 85 * [MediaProjectionStopDialogModel.Shown] when the dialog should be displayed, and 86 * [MediaProjectionStopDialogModel.Hidden] when it is dismissed. 87 */ 88 val stopDialogToShow: StateFlow<MediaProjectionStopDialogModel> = 89 _stopDialogToShow.asStateFlow() 90 91 /** 92 * Emits a [MediaProjectionStopDialogModel] based on the current projection state when a 93 * projectionStartedDuringCallAndActivePostCallEvent event is emitted. If projecting, determines 94 * the appropriate dialog type to show. Otherwise, emits a hidden dialog state. 95 */ 96 private val stopDialogDueToCallEndedState: StateFlow<MediaProjectionStopDialogModel> = 97 mediaProjectionChipInteractor.projectionStartedDuringCallAndActivePostCallEvent 98 .sample(mediaProjectionChipInteractor.projection) { _, currentProjection -> 99 when (currentProjection) { 100 is ProjectionChipModel.NotProjecting -> MediaProjectionStopDialogModel.Hidden 101 is ProjectionChipModel.Projecting -> { 102 when (currentProjection.receiver) { 103 ProjectionChipModel.Receiver.ShareToApp -> { 104 when (currentProjection.contentType) { 105 ProjectionChipModel.ContentType.Screen -> 106 createShareScreenToAppStopDialog(currentProjection) 107 ProjectionChipModel.ContentType.Audio -> 108 createGenericShareScreenToAppStopDialog() 109 } 110 } 111 ProjectionChipModel.Receiver.CastToOtherDevice -> 112 MediaProjectionStopDialogModel.Hidden 113 } 114 } 115 } 116 } 117 .stateIn(scope, SharingStarted.WhileSubscribed(), MediaProjectionStopDialogModel.Hidden) 118 119 /** 120 * Initializes background flow collector during SysUI startup for events determining the 121 * visibility of media projection stop dialogs. 122 */ 123 override fun start() { 124 if (com.android.media.projection.flags.Flags.showStopDialogPostCallEnd()) { 125 scope.launch { 126 stopDialogDueToCallEndedState.collect { event -> _stopDialogToShow.value = event } 127 } 128 } 129 } 130 131 private val internalChip = 132 mediaProjectionChipInteractor.projection 133 .map { projectionModel -> 134 when (projectionModel) { 135 is ProjectionChipModel.NotProjecting -> OngoingActivityChipModel.Inactive() 136 is ProjectionChipModel.Projecting -> { 137 when (projectionModel.receiver) { 138 ProjectionChipModel.Receiver.ShareToApp -> { 139 when (projectionModel.contentType) { 140 ProjectionChipModel.ContentType.Screen -> 141 createShareScreenToAppChip(projectionModel) 142 ProjectionChipModel.ContentType.Audio -> 143 createIconOnlyShareToAppChip() 144 } 145 } 146 ProjectionChipModel.Receiver.CastToOtherDevice -> 147 OngoingActivityChipModel.Inactive() 148 } 149 } 150 } 151 } 152 // See b/347726238 for [SharingStarted.Lazily] reasoning. 153 .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Inactive()) 154 155 private val chipTransitionHelper = ChipTransitionHelper(scope) 156 157 override val chip: StateFlow<OngoingActivityChipModel> = 158 combine(chipTransitionHelper.createChipFlow(internalChip), stopDialogToShow) { 159 currentChip, 160 stopDialog -> 161 if ( 162 com.android.media.projection.flags.Flags.showStopDialogPostCallEnd() && 163 stopDialog is MediaProjectionStopDialogModel.Shown 164 ) { 165 logger.log( 166 TAG, 167 LogLevel.INFO, 168 {}, 169 { "Hiding the chip as stop dialog is being shown" }, 170 ) 171 OngoingActivityChipModel.Inactive() 172 } else { 173 currentChip 174 } 175 } 176 .stateIn(scope, SharingStarted.WhileSubscribed(), OngoingActivityChipModel.Inactive()) 177 178 /** 179 * Notifies this class that the user just stopped a screen recording from the dialog that's 180 * shown when you tap the recording chip. 181 */ 182 fun onRecordingStoppedFromDialog() { 183 // When a screen recording is active, share-to-app is also active (screen recording is just 184 // a special case of share-to-app, where the specific app receiving the share is System UI). 185 // When a screen recording is stopped, we immediately hide the screen recording chip in 186 // [com.android.systemui.statusbar.chips.screenrecord.ui.viewmodel.ScreenRecordChipViewModel]. 187 // We *also* need to immediately hide the share-to-app chip so it doesn't briefly show. 188 // See b/350891338. 189 chipTransitionHelper.onActivityStoppedFromDialog() 190 } 191 192 /** Called when the stop dialog is dismissed or cancelled. */ 193 private fun onStopDialogDismissed() { 194 logger.log(TAG, LogLevel.INFO, {}, { "The media projection stop dialog was dismissed" }) 195 _stopDialogToShow.value = MediaProjectionStopDialogModel.Hidden 196 } 197 198 /** Stops the currently active projection. */ 199 private fun stopProjectingFromDialog() { 200 logger.log(TAG, LogLevel.INFO, {}, { "Stop sharing requested from dialog" }) 201 chipTransitionHelper.onActivityStoppedFromDialog() 202 mediaProjectionChipInteractor.stopProjecting() 203 } 204 205 private fun createShareScreenToAppStopDialog( 206 projectionModel: ProjectionChipModel.Projecting 207 ): MediaProjectionStopDialogModel { 208 val dialogDelegate = createShareScreenToAppDialogDelegate(projectionModel) 209 return MediaProjectionStopDialogModel.Shown( 210 dialogDelegate, 211 onDismissAction = ::onStopDialogDismissed, 212 ) 213 } 214 215 private fun createGenericShareScreenToAppStopDialog(): MediaProjectionStopDialogModel { 216 val dialogDelegate = createGenericShareToAppDialogDelegate() 217 return MediaProjectionStopDialogModel.Shown( 218 dialogDelegate, 219 onDismissAction = ::onStopDialogDismissed, 220 ) 221 } 222 223 private fun createShareScreenToAppChip( 224 state: ProjectionChipModel.Projecting 225 ): OngoingActivityChipModel.Active { 226 return OngoingActivityChipModel.Active.Timer( 227 key = KEY, 228 isImportantForPrivacy = true, 229 icon = 230 OngoingActivityChipModel.ChipIcon.SingleColorIcon( 231 Icon.Resource( 232 SHARE_TO_APP_ICON, 233 ContentDescription.Resource(R.string.share_to_app_chip_accessibility_label), 234 ) 235 ), 236 colors = ColorsModel.Red, 237 // TODO(b/332662551): Maybe use a MediaProjection API to fetch this time. 238 startTimeMs = systemClock.elapsedRealtime(), 239 onClickListenerLegacy = 240 createDialogLaunchOnClickListener( 241 createShareScreenToAppDialogDelegate(state), 242 dialogTransitionAnimator, 243 DIALOG_CUJ, 244 instanceId = instanceId, 245 uiEventLogger = uiEventLogger, 246 logger = logger, 247 tag = TAG, 248 ), 249 clickBehavior = 250 OngoingActivityChipModel.ClickBehavior.ExpandAction( 251 onClick = 252 createDialogLaunchOnClickCallback( 253 createShareScreenToAppDialogDelegate(state), 254 dialogTransitionAnimator, 255 DIALOG_CUJ, 256 instanceId = instanceId, 257 uiEventLogger = uiEventLogger, 258 logger = logger, 259 tag = TAG, 260 ) 261 ), 262 instanceId = instanceId, 263 ) 264 } 265 266 private fun createIconOnlyShareToAppChip(): OngoingActivityChipModel.Active { 267 return OngoingActivityChipModel.Active.IconOnly( 268 key = KEY, 269 isImportantForPrivacy = true, 270 icon = 271 OngoingActivityChipModel.ChipIcon.SingleColorIcon( 272 Icon.Resource( 273 SHARE_TO_APP_ICON, 274 ContentDescription.Resource( 275 R.string.share_to_app_chip_accessibility_label_generic 276 ), 277 ) 278 ), 279 colors = ColorsModel.Red, 280 onClickListenerLegacy = 281 createDialogLaunchOnClickListener( 282 createGenericShareToAppDialogDelegate(), 283 dialogTransitionAnimator, 284 DIALOG_CUJ_AUDIO_ONLY, 285 instanceId = instanceId, 286 uiEventLogger = uiEventLogger, 287 logger = logger, 288 tag = TAG, 289 ), 290 clickBehavior = 291 OngoingActivityChipModel.ClickBehavior.ExpandAction( 292 createDialogLaunchOnClickCallback( 293 createGenericShareToAppDialogDelegate(), 294 dialogTransitionAnimator, 295 DIALOG_CUJ_AUDIO_ONLY, 296 instanceId = instanceId, 297 uiEventLogger = uiEventLogger, 298 logger = logger, 299 tag = TAG, 300 ) 301 ), 302 instanceId = instanceId, 303 ) 304 } 305 306 private fun createShareScreenToAppDialogDelegate(state: ProjectionChipModel.Projecting) = 307 EndShareScreenToAppDialogDelegate( 308 endMediaProjectionDialogHelper, 309 context, 310 stopAction = this::stopProjectingFromDialog, 311 state, 312 ) 313 314 private fun createGenericShareToAppDialogDelegate() = 315 EndGenericShareToAppDialogDelegate( 316 endMediaProjectionDialogHelper, 317 context, 318 stopAction = this::stopProjectingFromDialog, 319 ) 320 321 companion object { 322 const val KEY = "ShareToApp" 323 @DrawableRes val SHARE_TO_APP_ICON = R.drawable.ic_present_to_all 324 private val DIALOG_CUJ = 325 DialogCuj(Cuj.CUJ_STATUS_BAR_LAUNCH_DIALOG_FROM_CHIP, tag = "Share to app") 326 private val DIALOG_CUJ_AUDIO_ONLY = 327 DialogCuj(Cuj.CUJ_STATUS_BAR_LAUNCH_DIALOG_FROM_CHIP, tag = "Share to app audio only") 328 private val TAG = "ShareToAppVM".pad() 329 } 330 } 331