1 /* 2 * Copyright (C) 2022 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 package com.android.systemui.mediaprojection.appselector 17 18 import android.app.ActivityOptions 19 import android.app.ActivityOptions.LaunchCookie 20 import android.content.Intent 21 import android.content.res.Configuration 22 import android.content.res.Resources 23 import android.media.projection.IMediaProjection 24 import android.media.projection.IMediaProjectionManager.EXTRA_USER_REVIEW_GRANTED_CONSENT 25 import android.media.projection.MediaProjectionManager.EXTRA_MEDIA_PROJECTION 26 import android.media.projection.ReviewGrantedConsentResult.RECORD_CANCEL 27 import android.media.projection.ReviewGrantedConsentResult.RECORD_CONTENT_TASK 28 import android.os.Bundle 29 import android.os.ResultReceiver 30 import android.os.UserHandle 31 import android.util.Log 32 import android.view.View 33 import android.view.ViewGroup 34 import android.view.accessibility.AccessibilityEvent 35 import android.widget.ImageView 36 import androidx.annotation.ColorRes 37 import androidx.annotation.DrawableRes 38 import androidx.annotation.StringRes 39 import androidx.lifecycle.Lifecycle 40 import androidx.lifecycle.LifecycleOwner 41 import androidx.lifecycle.LifecycleRegistry 42 import com.android.internal.annotations.VisibleForTesting 43 import com.android.internal.app.AbstractMultiProfilePagerAdapter.EmptyStateProvider 44 import com.android.internal.app.AbstractMultiProfilePagerAdapter.MyUserIdProvider 45 import com.android.internal.app.ChooserActivity 46 import com.android.internal.app.ResolverListController 47 import com.android.internal.app.chooser.NotSelectableTargetInfo 48 import com.android.internal.app.chooser.TargetInfo 49 import com.android.internal.widget.RecyclerView 50 import com.android.internal.widget.RecyclerViewAccessibilityDelegate 51 import com.android.internal.widget.ResolverDrawerLayout 52 import com.android.systemui.mediaprojection.MediaProjectionCaptureTarget 53 import com.android.systemui.mediaprojection.MediaProjectionServiceHelper 54 import com.android.systemui.mediaprojection.appselector.data.RecentTask 55 import com.android.systemui.mediaprojection.appselector.view.MediaProjectionRecentsViewController 56 import com.android.systemui.res.R 57 import com.android.systemui.shared.system.ActivityManagerWrapper 58 import com.android.systemui.statusbar.policy.ConfigurationController 59 import com.android.systemui.util.AsyncActivityLauncher 60 import java.lang.IllegalArgumentException 61 import javax.inject.Inject 62 63 class MediaProjectionAppSelectorActivity( 64 private val componentFactory: MediaProjectionAppSelectorComponent.Factory, 65 private val activityLauncher: AsyncActivityLauncher, 66 private val activityManager: ActivityManagerWrapper, 67 /** This is used to override the dependency in a screenshot test */ 68 @VisibleForTesting 69 private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)?, 70 ) : 71 ChooserActivity(), 72 MediaProjectionAppSelectorView, 73 MediaProjectionAppSelectorResultHandler, 74 LifecycleOwner { 75 76 @Inject 77 constructor( 78 componentFactory: MediaProjectionAppSelectorComponent.Factory, 79 activityLauncher: AsyncActivityLauncher, 80 activityManager: ActivityManagerWrapper, 81 ) : this(componentFactory, activityLauncher, activityManager, listControllerFactory = null) 82 83 private val lifecycleRegistry = LifecycleRegistry(this) 84 override val lifecycle = lifecycleRegistry 85 private lateinit var configurationController: ConfigurationController 86 private lateinit var controller: MediaProjectionAppSelectorController 87 private lateinit var recentsViewController: MediaProjectionRecentsViewController 88 private lateinit var component: MediaProjectionAppSelectorComponent 89 // Indicate if we are under the media projection security flow 90 // i.e. when a host app reuses consent token, review the permission and update it to the service 91 private var reviewGrantedConsentRequired = false 92 // If an app is selected, set to true so that we don't send RECORD_CANCEL in onDestroy 93 private var taskSelected = false 94 getLayoutResourcenull95 override fun getLayoutResource() = R.layout.media_projection_app_selector 96 97 public override fun onCreate(savedInstanceState: Bundle?) { 98 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) 99 component = 100 componentFactory.create( 101 hostUserHandle = hostUserHandle, 102 hostUid = hostUid, 103 callingPackage = callingPackage, 104 view = this, 105 resultHandler = this, 106 isFirstStart = savedInstanceState == null, 107 ) 108 component.lifecycleObservers.forEach { lifecycle.addObserver(it) } 109 110 // Create a separate configuration controller for this activity as the configuration 111 // might be different from the global one 112 configurationController = component.configurationController 113 controller = component.controller 114 recentsViewController = component.recentsViewController 115 116 intent.configureChooserIntent( 117 resources, 118 component.hostUserHandle, 119 component.personalProfileUserHandle, 120 ) 121 122 reviewGrantedConsentRequired = 123 intent.getBooleanExtra(EXTRA_USER_REVIEW_GRANTED_CONSENT, false) 124 125 super.onCreate(savedInstanceState) 126 controller.init() 127 setIcon() 128 // we override AppList's AccessibilityDelegate set in ResolverActivity.onCreate because in 129 // our case this delegate must extend RecyclerViewAccessibilityDelegate, otherwise 130 // RecyclerView scrolling is broken 131 setAppListAccessibilityDelegate() 132 } 133 onStartnull134 override fun onStart() { 135 super.onStart() 136 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) 137 } 138 onResumenull139 override fun onResume() { 140 super.onResume() 141 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 142 } 143 onPausenull144 override fun onPause() { 145 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) 146 super.onPause() 147 } 148 onStopnull149 override fun onStop() { 150 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) 151 super.onStop() 152 } 153 onConfigurationChangednull154 override fun onConfigurationChanged(newConfig: Configuration) { 155 super.onConfigurationChanged(newConfig) 156 configurationController.onConfigurationChanged(newConfig) 157 } 158 appliedThemeResIdnull159 override fun appliedThemeResId(): Int = R.style.Theme_SystemUI_MediaProjectionAppSelector 160 161 override fun createBlockerEmptyStateProvider(): EmptyStateProvider = 162 component.emptyStateProvider 163 164 override fun createListController(userHandle: UserHandle): ResolverListController = 165 listControllerFactory?.invoke(userHandle) ?: super.createListController(userHandle) 166 167 override fun startSelected(which: Int, always: Boolean, filtered: Boolean) { 168 val currentListAdapter = mChooserMultiProfilePagerAdapter.activeListAdapter 169 val targetInfo = currentListAdapter.targetInfoForPosition(which, filtered) ?: return 170 if (targetInfo is NotSelectableTargetInfo) return 171 172 val intent = createIntent(targetInfo) 173 174 val launchCookie = LaunchCookie("media_projection_launch_token") 175 val activityOptions = ActivityOptions.makeBasic() 176 activityOptions.setLaunchCookie(launchCookie) 177 178 val userHandle = mMultiProfilePagerAdapter.activeListAdapter.userHandle 179 180 // Launch activity asynchronously and wait for the result, launching of an activity 181 // is typically very fast, so we don't show any loaders. 182 // We wait for the activity to be launched to make sure that the window of the activity 183 // is created and ready to be captured. 184 val activityStarted = 185 activityLauncher.startActivityAsUser(intent, userHandle, activityOptions.toBundle()) { 186 if (targetInfo.resolvedComponentName == callingActivity) { 187 // If attempting to launch the app used to launch the MediaProjection, then 188 // provide the task id since the launch cookie won't match the existing task 189 returnSelectedApp(launchCookie, taskId = activityManager.runningTask.taskId) 190 } else { 191 returnSelectedApp(launchCookie, taskId = -1) 192 } 193 } 194 195 // Rely on the ActivityManager to pop up a dialog regarding app suspension 196 // and return false if suspended 197 if (!targetInfo.isSuspended && activityStarted) { 198 // TODO(b/222078415) track activity launch 199 } 200 } 201 createIntentnull202 private fun createIntent(target: TargetInfo): Intent { 203 val intent = Intent(target.resolvedIntent) 204 205 // Launch the app in a new task, so it won't be in the host's app task 206 intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK 207 208 // Remove activity forward result flag as this activity will 209 // return the media projection session 210 intent.flags = intent.flags and Intent.FLAG_ACTIVITY_FORWARD_RESULT.inv() 211 212 return intent 213 } 214 onDestroynull215 override fun onDestroy() { 216 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) 217 component.lifecycleObservers.forEach { lifecycle.removeObserver(it) } 218 // onDestroy is also called when an app is selected, in that case we only want to send 219 // RECORD_CONTENT_TASK but not RECORD_CANCEL 220 if (!taskSelected) { 221 // TODO(b/272010156): Return result to PermissionActivity and update service there 222 MediaProjectionServiceHelper.setReviewedConsentIfNeeded( 223 RECORD_CANCEL, 224 reviewGrantedConsentRequired, 225 /* projection= */ null, 226 ) 227 if (isFinishing) { 228 // Only log dismissed when actually finishing, and not when changing configuration. 229 controller.onSelectorDismissed() 230 } 231 } 232 activityLauncher.destroy() 233 controller.destroy() 234 super.onDestroy() 235 } 236 onActivityStartednull237 override fun onActivityStarted(cti: TargetInfo) { 238 // do nothing 239 } 240 bindnull241 override fun bind(recentTasks: List<RecentTask>) { 242 recentsViewController.bind(recentTasks) 243 if (!hasWorkProfile()) { 244 // Make sure to refresh the adapter, to show/hide the recents view depending on whether 245 // there are recents or not. 246 mMultiProfilePagerAdapter.personalListAdapter.notifyDataSetChanged() 247 } 248 } 249 returnSelectedAppnull250 override fun returnSelectedApp(launchCookie: LaunchCookie, taskId: Int) { 251 taskSelected = true 252 if (intent.hasExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER)) { 253 // The client requested to return the result in the result receiver instead of 254 // activity result, let's send the media projection to the result receiver 255 val resultReceiver = 256 intent.getParcelableExtra( 257 EXTRA_CAPTURE_REGION_RESULT_RECEIVER, 258 ResultReceiver::class.java, 259 ) as ResultReceiver 260 val captureRegion = MediaProjectionCaptureTarget(launchCookie, taskId) 261 val data = Bundle().apply { putParcelable(KEY_CAPTURE_TARGET, captureRegion) } 262 resultReceiver.send(RESULT_OK, data) 263 // TODO(b/279175710): Ensure consent result is always set here. Skipping this for now 264 // in ScreenMediaRecorder, since we know the permission grant (projection) is never 265 // reused in that scenario. 266 } else { 267 // TODO(b/272010156): Return result to PermissionActivity and update service there 268 // Return the media projection instance as activity result 269 val mediaProjectionBinder = intent.getIBinderExtra(EXTRA_MEDIA_PROJECTION) 270 val projection = IMediaProjection.Stub.asInterface(mediaProjectionBinder) 271 272 projection.launchCookie = launchCookie 273 projection.taskId = taskId 274 275 val intent = Intent() 276 intent.putExtra(EXTRA_MEDIA_PROJECTION, projection.asBinder()) 277 setResult(RESULT_OK, intent) 278 setForceSendResultForMediaProjection() 279 MediaProjectionServiceHelper.setReviewedConsentIfNeeded( 280 RECORD_CONTENT_TASK, 281 reviewGrantedConsentRequired, 282 projection, 283 ) 284 } 285 286 finish() 287 } 288 shouldGetOnlyDefaultActivitiesnull289 override fun shouldGetOnlyDefaultActivities() = false 290 291 override fun shouldShowContentPreview() = 292 if (hasWorkProfile()) { 293 // When the user has a work profile, we can always set this to true, and the layout is 294 // adjusted automatically, and hide the recents view. 295 true 296 } else { 297 // When there is no work profile, we should only show the content preview if there are 298 // recents, otherwise the collapsed app selector will look empty. 299 recentsViewController.hasRecentTasks 300 } 301 shouldShowStickyContentPreviewWhenEmptynull302 override fun shouldShowStickyContentPreviewWhenEmpty() = shouldShowContentPreview() 303 304 override fun shouldShowServiceTargets() = false 305 306 private fun hasWorkProfile() = mMultiProfilePagerAdapter.count > 1 307 308 override fun createMyUserIdProvider(): MyUserIdProvider = 309 object : MyUserIdProvider() { 310 override fun getMyUserId(): Int = component.hostUserHandle.identifier 311 } 312 createContentPreviewViewnull313 override fun createContentPreviewView(parent: ViewGroup): ViewGroup = 314 recentsViewController.createView(parent) 315 316 /** Set up intent for the [ChooserActivity] */ 317 private fun Intent.configureChooserIntent( 318 resources: Resources, 319 hostUserHandle: UserHandle, 320 personalProfileUserHandle: UserHandle, 321 ) { 322 // Specify the query intent to show icons for all apps on the chooser screen 323 val queryIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } 324 putExtra(Intent.EXTRA_INTENT, queryIntent) 325 326 // Update the title of the chooser 327 putExtra(Intent.EXTRA_TITLE, resources.getString(titleResId)) 328 329 // Select host app's profile tab by default 330 val selectedProfile = 331 if (hostUserHandle == personalProfileUserHandle) { 332 PROFILE_PERSONAL 333 } else { 334 PROFILE_WORK 335 } 336 putExtra(EXTRA_SELECTED_PROFILE, selectedProfile) 337 } 338 339 private val hostUserHandle: UserHandle 340 get() { 341 val extras = 342 intent.extras 343 ?: error("MediaProjectionAppSelectorActivity should be launched with extras") 344 return extras.getParcelable(EXTRA_HOST_APP_USER_HANDLE) 345 ?: error( 346 "MediaProjectionAppSelectorActivity should be provided with " + 347 "$EXTRA_HOST_APP_USER_HANDLE extra" 348 ) 349 } 350 351 private val hostUid: Int 352 get() { 353 if (!intent.hasExtra(EXTRA_HOST_APP_UID)) { 354 error( 355 "MediaProjectionAppSelectorActivity should be provided with " + 356 "$EXTRA_HOST_APP_UID extra" 357 ) 358 } 359 return intent.getIntExtra(EXTRA_HOST_APP_UID, /* defaultValue= */ -1) 360 } 361 362 /** 363 * The type of screen sharing being performed. Used to show the right text and icon in the 364 * activity. 365 */ 366 private val screenShareType: ScreenShareType? 367 get() { 368 if (!intent.hasExtra(EXTRA_SCREEN_SHARE_TYPE)) { 369 return null 370 } else { 371 val type = intent.getStringExtra(EXTRA_SCREEN_SHARE_TYPE) ?: return null 372 return try { 373 enumValueOf<ScreenShareType>(type) 374 } catch (e: IllegalArgumentException) { 375 null 376 } 377 } 378 } 379 380 @get:StringRes 381 private val titleResId: Int 382 get() = 383 when (screenShareType) { 384 ScreenShareType.ShareToApp -> 385 R.string.media_projection_entry_share_app_selector_title 386 ScreenShareType.SystemCast -> 387 R.string.media_projection_entry_cast_app_selector_title 388 ScreenShareType.ScreenRecord -> R.string.screenrecord_app_selector_title 389 null -> R.string.screen_share_generic_app_selector_title 390 } 391 392 @get:DrawableRes 393 private val iconResId: Int 394 get() = 395 when (screenShareType) { 396 ScreenShareType.ShareToApp -> R.drawable.ic_present_to_all 397 ScreenShareType.SystemCast -> R.drawable.ic_cast_connected 398 ScreenShareType.ScreenRecord -> R.drawable.ic_screenrecord 399 null -> R.drawable.ic_present_to_all 400 } 401 402 @get:ColorRes 403 private val iconTintResId: Int? 404 get() = 405 when (screenShareType) { 406 ScreenShareType.ScreenRecord -> R.color.screenrecord_icon_color 407 else -> null 408 } 409 410 companion object { 411 const val TAG = "MediaProjectionAppSelectorActivity" 412 413 /** 414 * When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra the activity will 415 * send the [CaptureRegion] to the result receiver instead of returning media projection 416 * instance through activity result. 417 */ 418 const val EXTRA_CAPTURE_REGION_RESULT_RECEIVER = "capture_region_result_receiver" 419 420 /** 421 * User on the device that launched the media projection flow. (Primary, Secondary, Guest, 422 * Work, etc) 423 */ 424 const val EXTRA_HOST_APP_USER_HANDLE = "launched_from_user_handle" 425 /** 426 * The kernel user-ID that has been assigned to the app that originally launched the media 427 * projection flow. 428 */ 429 const val EXTRA_HOST_APP_UID = "launched_from_host_uid" 430 const val KEY_CAPTURE_TARGET = "capture_region" 431 432 /** 433 * The type of screen sharing being performed. 434 * 435 * The value set for this extra should match the name of a [ScreenShareType]. 436 */ 437 const val EXTRA_SCREEN_SHARE_TYPE = "screen_share_type" 438 } 439 setIconnull440 private fun setIcon() { 441 val iconView = findViewById<ImageView>(R.id.media_projection_app_selector_icon) ?: return 442 iconView.setImageResource(iconResId) 443 iconTintResId?.let { iconView.setColorFilter(this.resources.getColor(it, this.theme)) } 444 } 445 setAppListAccessibilityDelegatenull446 private fun setAppListAccessibilityDelegate() { 447 val rdl = requireViewById<ResolverDrawerLayout>(com.android.internal.R.id.contentPanel) 448 for (i in 0 until mMultiProfilePagerAdapter.count) { 449 val list = 450 mMultiProfilePagerAdapter 451 .getItem(i) 452 .rootView 453 .findViewById<View>(com.android.internal.R.id.resolver_list) 454 if (list == null || list !is RecyclerView) { 455 Log.wtf(TAG, "MediaProjection only supports RecyclerView") 456 } else { 457 list.accessibilityDelegate = RecyclerViewExpandingAccessibilityDelegate(rdl, list) 458 } 459 } 460 } 461 462 /** 463 * An a11y delegate propagating all a11y events to [AppListAccessibilityDelegate] so that it can 464 * expand drawer when needed. It needs to extend [RecyclerViewAccessibilityDelegate] because 465 * that superclass handles RecyclerView scrolling while using a11y services. 466 */ 467 private class RecyclerViewExpandingAccessibilityDelegate( 468 rdl: ResolverDrawerLayout, 469 view: RecyclerView, 470 ) : RecyclerViewAccessibilityDelegate(view) { 471 472 private val delegate = AppListAccessibilityDelegate(rdl) 473 onRequestSendAccessibilityEventnull474 override fun onRequestSendAccessibilityEvent( 475 host: ViewGroup, 476 child: View, 477 event: AccessibilityEvent, 478 ): Boolean { 479 super.onRequestSendAccessibilityEvent(host, child, event) 480 return delegate.onRequestSendAccessibilityEvent(host, child, event) 481 } 482 } 483 484 /** Enum describing what type of app screen sharing is being performed. */ 485 enum class ScreenShareType { 486 /** The selected app will be cast to another device. */ 487 SystemCast, 488 /** The selected app will be shared to another app on the device. */ 489 ShareToApp, 490 /** The selected app will be recorded. */ 491 ScreenRecord, 492 } 493 } 494