1 /* 2 * 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.mediaprojection.data.repository 18 19 import android.app.ActivityManager.RunningTaskInfo 20 import android.hardware.display.DisplayManager 21 import android.media.projection.MediaProjectionEvent 22 import android.media.projection.MediaProjectionInfo 23 import android.media.projection.MediaProjectionManager 24 import android.media.projection.StopReason 25 import android.os.Handler 26 import android.view.ContentRecordingSession 27 import android.view.ContentRecordingSession.RECORD_CONTENT_DISPLAY 28 import com.android.systemui.Flags 29 import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging 30 import com.android.systemui.dagger.SysUISingleton 31 import com.android.systemui.dagger.qualifiers.Application 32 import com.android.systemui.dagger.qualifiers.Background 33 import com.android.systemui.dagger.qualifiers.Main 34 import com.android.systemui.log.LogBuffer 35 import com.android.systemui.log.core.LogLevel 36 import com.android.systemui.mediaprojection.MediaProjectionLog 37 import com.android.systemui.mediaprojection.MediaProjectionServiceHelper 38 import com.android.systemui.mediaprojection.data.model.MediaProjectionState 39 import com.android.systemui.mediaprojection.taskswitcher.data.repository.TasksRepository 40 import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow 41 import javax.inject.Inject 42 import kotlinx.coroutines.CoroutineDispatcher 43 import kotlinx.coroutines.CoroutineScope 44 import kotlinx.coroutines.channels.awaitClose 45 import kotlinx.coroutines.flow.Flow 46 import kotlinx.coroutines.flow.SharingStarted 47 import kotlinx.coroutines.flow.filter 48 import kotlinx.coroutines.flow.filterNot 49 import kotlinx.coroutines.flow.map 50 import kotlinx.coroutines.flow.mapLatest 51 import kotlinx.coroutines.flow.stateIn 52 import kotlinx.coroutines.withContext 53 54 @SysUISingleton 55 class MediaProjectionManagerRepository 56 @Inject 57 constructor( 58 private val mediaProjectionManager: MediaProjectionManager, 59 private val displayManager: DisplayManager, 60 @Main private val handler: Handler, 61 @Application private val applicationScope: CoroutineScope, 62 @Background private val backgroundDispatcher: CoroutineDispatcher, 63 private val tasksRepository: TasksRepository, 64 private val mediaProjectionServiceHelper: MediaProjectionServiceHelper, 65 @MediaProjectionLog private val logger: LogBuffer, 66 ) : MediaProjectionRepository { 67 switchProjectedTasknull68 override suspend fun switchProjectedTask(task: RunningTaskInfo) { 69 withContext(backgroundDispatcher) { 70 if (mediaProjectionServiceHelper.updateTaskRecordingSession(task.token)) { 71 logger.log(TAG, LogLevel.DEBUG, {}, { "Successfully switched projected task" }) 72 } else { 73 logger.log(TAG, LogLevel.WARNING, {}, { "Failed to switch projected task" }) 74 } 75 } 76 } 77 stopProjectingnull78 override suspend fun stopProjecting(@StopReason stopReason: Int) { 79 withContext(backgroundDispatcher) { 80 logger.log( 81 TAG, 82 LogLevel.DEBUG, 83 {}, 84 { "Requesting MediaProjectionManager#stopActiveProjection" }, 85 ) 86 mediaProjectionManager.stopActiveProjection(stopReason) 87 } 88 } 89 <lambda>null90 private val callbackEventsFlow = conflatedCallbackFlow { 91 val callback = 92 object : MediaProjectionManager.Callback() { 93 override fun onStart(info: MediaProjectionInfo?) { 94 logger.log(TAG, LogLevel.DEBUG, {}, { "Callback#onStart" }) 95 trySendWithFailureLogging(CallbackEvent.OnStart(info), TAG) 96 } 97 98 override fun onStop(info: MediaProjectionInfo?) { 99 logger.log(TAG, LogLevel.DEBUG, {}, { "Callback#onStop" }) 100 trySendWithFailureLogging(CallbackEvent.OnStop, TAG) 101 } 102 103 override fun onRecordingSessionSet( 104 info: MediaProjectionInfo, 105 session: ContentRecordingSession?, 106 ) { 107 logger.log( 108 TAG, 109 LogLevel.DEBUG, 110 { str1 = session.toString() }, 111 { "Callback#onSessionSet: $str1" }, 112 ) 113 trySendWithFailureLogging( 114 CallbackEvent.OnRecordingSessionSet(info, session), 115 TAG, 116 ) 117 } 118 119 override fun onMediaProjectionEvent( 120 event: MediaProjectionEvent, 121 info: MediaProjectionInfo?, 122 session: ContentRecordingSession?, 123 ) { 124 if (com.android.media.projection.flags.Flags.showStopDialogPostCallEnd()) { 125 logger.log( 126 TAG, 127 LogLevel.DEBUG, 128 { str1 = event.toString() }, 129 { "Callback#onMediaProjectionEvent : $str1" }, 130 ) 131 trySendWithFailureLogging(CallbackEvent.OnMediaProjectionEvent(event), TAG) 132 } 133 } 134 } 135 mediaProjectionManager.addCallback(callback, handler) 136 awaitClose { mediaProjectionManager.removeCallback(callback) } 137 } 138 139 override val mediaProjectionState: Flow<MediaProjectionState> = 140 callbackEventsFlow <lambda>null141 .filterNot { 142 it is CallbackEvent.OnMediaProjectionEvent // Exclude OnMediaProjectionEvent 143 } 144 // When we get an #onRecordingSessionSet event, we need to do some work in the 145 // background before emitting the right state value. But when we get an #onStop 146 // event, we immediately know what state value to emit. 147 // 148 // Without `mapLatest`, this could be a problem if an #onRecordingSessionSet event 149 // comes in and then an #onStop event comes in shortly afterwards (b/352483752): 150 // 1. #onRecordingSessionSet -> start some work in the background 151 // 2. #onStop -> immediately emit "Not Projecting" 152 // 3. onRecordingSessionSet work finishes -> emit "Projecting" 153 // 154 // At step 3, we *shouldn't* emit "Projecting" because #onStop was the last callback 155 // event we received, so we should be "Not Projecting". This `mapLatest` ensures 156 // that if an #onStop event comes in, we cancel any ongoing work for 157 // #onRecordingSessionSet and we don't emit "Projecting". <lambda>null158 .mapLatest { 159 when (it) { 160 is CallbackEvent.OnStart -> { 161 if (!Flags.statusBarShowAudioOnlyProjectionChip()) { 162 return@mapLatest MediaProjectionState.NotProjecting 163 } 164 // It's possible for a projection to be audio-only, in which case `OnStart` 165 // will occur but `OnRecordingSessionSet` will not. We should still consider 166 // us to be projecting even if only audio is projecting. See b/373308507. 167 if (it.info != null) { 168 MediaProjectionState.Projecting.NoScreen( 169 hostPackage = it.info.packageName 170 ) 171 } else { 172 MediaProjectionState.NotProjecting 173 } 174 } 175 is CallbackEvent.OnStop -> MediaProjectionState.NotProjecting 176 is CallbackEvent.OnRecordingSessionSet -> stateForSession(it.info, it.session) 177 is CallbackEvent.OnMediaProjectionEvent -> 178 throw IllegalStateException( 179 "Unexpected OnMediaProjectionEvent in mediaProjectionState flow. It " + 180 "should have been filtered out." 181 ) 182 } 183 } 184 .stateIn( 185 scope = applicationScope, 186 started = SharingStarted.Lazily, 187 initialValue = MediaProjectionState.NotProjecting, 188 ) 189 190 override val projectionStartedDuringCallAndActivePostCallEvent: Flow<Unit> = 191 callbackEventsFlow <lambda>null192 .filter { 193 com.android.media.projection.flags.Flags.showStopDialogPostCallEnd() && 194 it is CallbackEvent.OnMediaProjectionEvent && 195 it.event.eventType == 196 MediaProjectionEvent.PROJECTION_STARTED_DURING_CALL_AND_ACTIVE_POST_CALL 197 } <lambda>null198 .map {} 199 stateForSessionnull200 private suspend fun stateForSession( 201 info: MediaProjectionInfo, 202 session: ContentRecordingSession?, 203 ): MediaProjectionState { 204 if (session == null) { 205 return MediaProjectionState.NotProjecting 206 } 207 208 val hostPackage = info.packageName 209 val hostDeviceName = 210 withContext(backgroundDispatcher) { 211 // If the projection is to a different device, then the session's display ID should 212 // identify the display associated with that different device. 213 displayManager.getDisplay(session.virtualDisplayId)?.name 214 } 215 216 if (session.contentToRecord == RECORD_CONTENT_DISPLAY || session.tokenToRecord == null) { 217 return MediaProjectionState.Projecting.EntireScreen(hostPackage, hostDeviceName) 218 } 219 val matchingTask = 220 tasksRepository.findRunningTaskFromWindowContainerToken( 221 checkNotNull(session.tokenToRecord) 222 ) ?: return MediaProjectionState.Projecting.EntireScreen(hostPackage, hostDeviceName) 223 return MediaProjectionState.Projecting.SingleTask(hostPackage, hostDeviceName, matchingTask) 224 } 225 226 /** 227 * Translates [MediaProjectionManager.Callback] events into objects so that we always maintain 228 * the correct callback ordering. 229 */ 230 sealed interface CallbackEvent { 231 data class OnStart(val info: MediaProjectionInfo?) : CallbackEvent 232 233 data object OnStop : CallbackEvent 234 235 data class OnRecordingSessionSet( 236 val info: MediaProjectionInfo, 237 val session: ContentRecordingSession?, 238 ) : CallbackEvent 239 240 data class OnMediaProjectionEvent(val event: MediaProjectionEvent) : CallbackEvent 241 } 242 243 companion object { 244 private const val TAG = "MediaProjectionMngrRepo" 245 } 246 } 247