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 androidx.lifecycle.Lifecycle 36 import androidx.lifecycle.LifecycleOwner 37 import androidx.lifecycle.LifecycleRegistry 38 import com.android.internal.annotations.VisibleForTesting 39 import com.android.internal.app.AbstractMultiProfilePagerAdapter.EmptyStateProvider 40 import com.android.internal.app.AbstractMultiProfilePagerAdapter.MyUserIdProvider 41 import com.android.internal.app.ChooserActivity 42 import com.android.internal.app.ResolverListController 43 import com.android.internal.app.chooser.NotSelectableTargetInfo 44 import com.android.internal.app.chooser.TargetInfo 45 import com.android.internal.widget.RecyclerView 46 import com.android.internal.widget.RecyclerViewAccessibilityDelegate 47 import com.android.internal.widget.ResolverDrawerLayout 48 import com.android.systemui.mediaprojection.MediaProjectionCaptureTarget 49 import com.android.systemui.mediaprojection.MediaProjectionServiceHelper 50 import com.android.systemui.mediaprojection.appselector.data.RecentTask 51 import com.android.systemui.mediaprojection.appselector.view.MediaProjectionRecentsViewController 52 import com.android.systemui.res.R 53 import com.android.systemui.statusbar.policy.ConfigurationController 54 import com.android.systemui.util.AsyncActivityLauncher 55 import javax.inject.Inject 56 57 class MediaProjectionAppSelectorActivity( 58 private val componentFactory: MediaProjectionAppSelectorComponent.Factory, 59 private val activityLauncher: AsyncActivityLauncher, 60 /** This is used to override the dependency in a screenshot test */ 61 @VisibleForTesting 62 private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)? 63 ) : 64 ChooserActivity(), 65 MediaProjectionAppSelectorView, 66 MediaProjectionAppSelectorResultHandler, 67 LifecycleOwner { 68 69 @Inject 70 constructor( 71 componentFactory: MediaProjectionAppSelectorComponent.Factory, 72 activityLauncher: AsyncActivityLauncher 73 ) : this(componentFactory, activityLauncher, listControllerFactory = null) 74 75 private val lifecycleRegistry = LifecycleRegistry(this) 76 override val lifecycle = lifecycleRegistry 77 private lateinit var configurationController: ConfigurationController 78 private lateinit var controller: MediaProjectionAppSelectorController 79 private lateinit var recentsViewController: MediaProjectionRecentsViewController 80 private lateinit var component: MediaProjectionAppSelectorComponent 81 // Indicate if we are under the media projection security flow 82 // i.e. when a host app reuses consent token, review the permission and update it to the service 83 private var reviewGrantedConsentRequired = false 84 // If an app is selected, set to true so that we don't send RECORD_CANCEL in onDestroy 85 private var taskSelected = false 86 getLayoutResourcenull87 override fun getLayoutResource() = R.layout.media_projection_app_selector 88 89 public override fun onCreate(savedInstanceState: Bundle?) { 90 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) 91 component = 92 componentFactory.create( 93 hostUserHandle = hostUserHandle, 94 hostUid = hostUid, 95 callingPackage = callingPackage, 96 view = this, 97 resultHandler = this, 98 isFirstStart = savedInstanceState == null 99 ) 100 component.lifecycleObservers.forEach { lifecycle.addObserver(it) } 101 102 // Create a separate configuration controller for this activity as the configuration 103 // might be different from the global one 104 configurationController = component.configurationController 105 controller = component.controller 106 recentsViewController = component.recentsViewController 107 108 intent.configureChooserIntent( 109 resources, 110 component.hostUserHandle, 111 component.personalProfileUserHandle 112 ) 113 114 reviewGrantedConsentRequired = 115 intent.getBooleanExtra(EXTRA_USER_REVIEW_GRANTED_CONSENT, false) 116 117 super.onCreate(savedInstanceState) 118 controller.init() 119 // we override AppList's AccessibilityDelegate set in ResolverActivity.onCreate because in 120 // our case this delegate must extend RecyclerViewAccessibilityDelegate, otherwise 121 // RecyclerView scrolling is broken 122 setAppListAccessibilityDelegate() 123 } 124 onStartnull125 override fun onStart() { 126 super.onStart() 127 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) 128 } 129 onResumenull130 override fun onResume() { 131 super.onResume() 132 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) 133 } 134 onPausenull135 override fun onPause() { 136 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) 137 super.onPause() 138 } 139 onStopnull140 override fun onStop() { 141 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) 142 super.onStop() 143 } 144 onConfigurationChangednull145 override fun onConfigurationChanged(newConfig: Configuration) { 146 super.onConfigurationChanged(newConfig) 147 configurationController.onConfigurationChanged(newConfig) 148 } 149 appliedThemeResIdnull150 override fun appliedThemeResId(): Int = R.style.Theme_SystemUI_MediaProjectionAppSelector 151 152 override fun createBlockerEmptyStateProvider(): EmptyStateProvider = 153 component.emptyStateProvider 154 155 override fun createListController(userHandle: UserHandle): ResolverListController = 156 listControllerFactory?.invoke(userHandle) ?: super.createListController(userHandle) 157 158 override fun startSelected(which: Int, always: Boolean, filtered: Boolean) { 159 val currentListAdapter = mChooserMultiProfilePagerAdapter.activeListAdapter 160 val targetInfo = currentListAdapter.targetInfoForPosition(which, filtered) ?: return 161 if (targetInfo is NotSelectableTargetInfo) return 162 163 val intent = createIntent(targetInfo) 164 165 val launchCookie = LaunchCookie("media_projection_launch_token") 166 val activityOptions = ActivityOptions.makeBasic() 167 activityOptions.setLaunchCookie(launchCookie) 168 169 val userHandle = mMultiProfilePagerAdapter.activeListAdapter.userHandle 170 171 // Launch activity asynchronously and wait for the result, launching of an activity 172 // is typically very fast, so we don't show any loaders. 173 // We wait for the activity to be launched to make sure that the window of the activity 174 // is created and ready to be captured. 175 val activityStarted = 176 activityLauncher.startActivityAsUser(intent, userHandle, activityOptions.toBundle()) { 177 returnSelectedApp(launchCookie, taskId = -1) 178 } 179 180 // Rely on the ActivityManager to pop up a dialog regarding app suspension 181 // and return false if suspended 182 if (!targetInfo.isSuspended && activityStarted) { 183 // TODO(b/222078415) track activity launch 184 } 185 } 186 createIntentnull187 private fun createIntent(target: TargetInfo): Intent { 188 val intent = Intent(target.resolvedIntent) 189 190 // Launch the app in a new task, so it won't be in the host's app task 191 intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK 192 193 // Remove activity forward result flag as this activity will 194 // return the media projection session 195 intent.flags = intent.flags and Intent.FLAG_ACTIVITY_FORWARD_RESULT.inv() 196 197 return intent 198 } 199 onDestroynull200 override fun onDestroy() { 201 lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) 202 component.lifecycleObservers.forEach { lifecycle.removeObserver(it) } 203 // onDestroy is also called when an app is selected, in that case we only want to send 204 // RECORD_CONTENT_TASK but not RECORD_CANCEL 205 if (!taskSelected) { 206 // TODO(b/272010156): Return result to PermissionActivity and update service there 207 MediaProjectionServiceHelper.setReviewedConsentIfNeeded( 208 RECORD_CANCEL, 209 reviewGrantedConsentRequired, 210 /* projection= */ null 211 ) 212 if (isFinishing) { 213 // Only log dismissed when actually finishing, and not when changing configuration. 214 controller.onSelectorDismissed() 215 } 216 } 217 activityLauncher.destroy() 218 controller.destroy() 219 super.onDestroy() 220 } 221 onActivityStartednull222 override fun onActivityStarted(cti: TargetInfo) { 223 // do nothing 224 } 225 bindnull226 override fun bind(recentTasks: List<RecentTask>) { 227 recentsViewController.bind(recentTasks) 228 if (!hasWorkProfile()) { 229 // Make sure to refresh the adapter, to show/hide the recents view depending on whether 230 // there are recents or not. 231 mMultiProfilePagerAdapter.personalListAdapter.notifyDataSetChanged() 232 } 233 } 234 returnSelectedAppnull235 override fun returnSelectedApp(launchCookie: LaunchCookie, taskId: Int) { 236 taskSelected = true 237 if (intent.hasExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER)) { 238 // The client requested to return the result in the result receiver instead of 239 // activity result, let's send the media projection to the result receiver 240 val resultReceiver = 241 intent.getParcelableExtra( 242 EXTRA_CAPTURE_REGION_RESULT_RECEIVER, 243 ResultReceiver::class.java 244 ) as ResultReceiver 245 val captureRegion = MediaProjectionCaptureTarget(launchCookie, taskId) 246 val data = Bundle().apply { putParcelable(KEY_CAPTURE_TARGET, captureRegion) } 247 resultReceiver.send(RESULT_OK, data) 248 // TODO(b/279175710): Ensure consent result is always set here. Skipping this for now 249 // in ScreenMediaRecorder, since we know the permission grant (projection) is never 250 // reused in that scenario. 251 } else { 252 // TODO(b/272010156): Return result to PermissionActivity and update service there 253 // Return the media projection instance as activity result 254 val mediaProjectionBinder = intent.getIBinderExtra(EXTRA_MEDIA_PROJECTION) 255 val projection = IMediaProjection.Stub.asInterface(mediaProjectionBinder) 256 257 projection.setLaunchCookie(launchCookie) 258 projection.setTaskId(taskId) 259 260 val intent = Intent() 261 intent.putExtra(EXTRA_MEDIA_PROJECTION, projection.asBinder()) 262 setResult(RESULT_OK, intent) 263 setForceSendResultForMediaProjection() 264 MediaProjectionServiceHelper.setReviewedConsentIfNeeded( 265 RECORD_CONTENT_TASK, 266 reviewGrantedConsentRequired, 267 projection 268 ) 269 } 270 271 finish() 272 } 273 shouldGetOnlyDefaultActivitiesnull274 override fun shouldGetOnlyDefaultActivities() = false 275 276 override fun shouldShowContentPreview() = 277 if (hasWorkProfile()) { 278 // When the user has a work profile, we can always set this to true, and the layout is 279 // adjusted automatically, and hide the recents view. 280 true 281 } else { 282 // When there is no work profile, we should only show the content preview if there are 283 // recents, otherwise the collapsed app selector will look empty. 284 recentsViewController.hasRecentTasks 285 } 286 shouldShowStickyContentPreviewWhenEmptynull287 override fun shouldShowStickyContentPreviewWhenEmpty() = shouldShowContentPreview() 288 289 override fun shouldShowServiceTargets() = false 290 291 private fun hasWorkProfile() = mMultiProfilePagerAdapter.count > 1 292 293 override fun createMyUserIdProvider(): MyUserIdProvider = 294 object : MyUserIdProvider() { 295 override fun getMyUserId(): Int = component.hostUserHandle.identifier 296 } 297 createContentPreviewViewnull298 override fun createContentPreviewView(parent: ViewGroup): ViewGroup = 299 recentsViewController.createView(parent) 300 301 private val hostUserHandle: UserHandle 302 get() { 303 val extras = 304 intent.extras 305 ?: error("MediaProjectionAppSelectorActivity should be launched with extras") 306 return extras.getParcelable(EXTRA_HOST_APP_USER_HANDLE) 307 ?: error( 308 "MediaProjectionAppSelectorActivity should be provided with " + 309 "$EXTRA_HOST_APP_USER_HANDLE extra" 310 ) 311 } 312 313 private val hostUid: Int 314 get() { 315 if (!intent.hasExtra(EXTRA_HOST_APP_UID)) { 316 error( 317 "MediaProjectionAppSelectorActivity should be provided with " + 318 "$EXTRA_HOST_APP_UID extra" 319 ) 320 } 321 return intent.getIntExtra(EXTRA_HOST_APP_UID, /* defaultValue= */ -1) 322 } 323 324 companion object { 325 const val TAG = "MediaProjectionAppSelectorActivity" 326 327 /** 328 * When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra the activity will 329 * send the [CaptureRegion] to the result receiver instead of returning media projection 330 * instance through activity result. 331 */ 332 const val EXTRA_CAPTURE_REGION_RESULT_RECEIVER = "capture_region_result_receiver" 333 334 /** 335 * User on the device that launched the media projection flow. (Primary, Secondary, Guest, 336 * Work, etc) 337 */ 338 const val EXTRA_HOST_APP_USER_HANDLE = "launched_from_user_handle" 339 /** 340 * The kernel user-ID that has been assigned to the app that originally launched the media 341 * projection flow. 342 */ 343 const val EXTRA_HOST_APP_UID = "launched_from_host_uid" 344 const val KEY_CAPTURE_TARGET = "capture_region" 345 346 /** Set up intent for the [ChooserActivity] */ Intentnull347 private fun Intent.configureChooserIntent( 348 resources: Resources, 349 hostUserHandle: UserHandle, 350 personalProfileUserHandle: UserHandle 351 ) { 352 // Specify the query intent to show icons for all apps on the chooser screen 353 val queryIntent = 354 Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } 355 putExtra(Intent.EXTRA_INTENT, queryIntent) 356 357 // Update the title of the chooser 358 val title = resources.getString(R.string.screen_share_permission_app_selector_title) 359 putExtra(Intent.EXTRA_TITLE, title) 360 361 // Select host app's profile tab by default 362 val selectedProfile = 363 if (hostUserHandle == personalProfileUserHandle) { 364 PROFILE_PERSONAL 365 } else { 366 PROFILE_WORK 367 } 368 putExtra(EXTRA_SELECTED_PROFILE, selectedProfile) 369 } 370 } 371 setAppListAccessibilityDelegatenull372 private fun setAppListAccessibilityDelegate() { 373 val rdl = requireViewById<ResolverDrawerLayout>(com.android.internal.R.id.contentPanel) 374 for (i in 0 until mMultiProfilePagerAdapter.count) { 375 val list = 376 mMultiProfilePagerAdapter 377 .getItem(i) 378 .rootView 379 .findViewById<View>(com.android.internal.R.id.resolver_list) 380 if (list == null || list !is RecyclerView) { 381 Log.wtf(TAG, "MediaProjection only supports RecyclerView") 382 } else { 383 list.accessibilityDelegate = RecyclerViewExpandingAccessibilityDelegate(rdl, list) 384 } 385 } 386 } 387 388 /** 389 * An a11y delegate propagating all a11y events to [AppListAccessibilityDelegate] so that it can 390 * expand drawer when needed. It needs to extend [RecyclerViewAccessibilityDelegate] because 391 * that superclass handles RecyclerView scrolling while using a11y services. 392 */ 393 private class RecyclerViewExpandingAccessibilityDelegate( 394 rdl: ResolverDrawerLayout, 395 view: RecyclerView 396 ) : RecyclerViewAccessibilityDelegate(view) { 397 398 private val delegate = AppListAccessibilityDelegate(rdl) 399 onRequestSendAccessibilityEventnull400 override fun onRequestSendAccessibilityEvent( 401 host: ViewGroup, 402 child: View, 403 event: AccessibilityEvent 404 ): Boolean { 405 super.onRequestSendAccessibilityEvent(host, child, event) 406 return delegate.onRequestSendAccessibilityEvent(host, child, event) 407 } 408 } 409 } 410