/* * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.media import android.app.ActivityOptions import android.content.Intent import android.content.res.Configuration import android.content.res.Resources import android.media.projection.IMediaProjection import android.media.projection.IMediaProjectionManager.EXTRA_USER_REVIEW_GRANTED_CONSENT import android.media.projection.MediaProjectionManager.EXTRA_MEDIA_PROJECTION import android.media.projection.ReviewGrantedConsentResult.RECORD_CANCEL import android.media.projection.ReviewGrantedConsentResult.RECORD_CONTENT_TASK import android.os.Binder import android.os.Bundle import android.os.IBinder import android.os.ResultReceiver import android.os.UserHandle import android.util.Log import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.android.internal.annotations.VisibleForTesting import com.android.internal.app.AbstractMultiProfilePagerAdapter.EmptyStateProvider import com.android.internal.app.AbstractMultiProfilePagerAdapter.MyUserIdProvider import com.android.internal.app.ChooserActivity import com.android.internal.app.ResolverListController import com.android.internal.app.chooser.NotSelectableTargetInfo import com.android.internal.app.chooser.TargetInfo import com.android.internal.widget.RecyclerView import com.android.internal.widget.RecyclerViewAccessibilityDelegate import com.android.internal.widget.ResolverDrawerLayout import com.android.systemui.R import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorComponent import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorController import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorResultHandler import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorView import com.android.systemui.mediaprojection.appselector.data.RecentTask import com.android.systemui.mediaprojection.appselector.view.MediaProjectionRecentsViewController import com.android.systemui.statusbar.policy.ConfigurationController import com.android.systemui.util.AsyncActivityLauncher import javax.inject.Inject class MediaProjectionAppSelectorActivity( private val componentFactory: MediaProjectionAppSelectorComponent.Factory, private val activityLauncher: AsyncActivityLauncher, /** This is used to override the dependency in a screenshot test */ @VisibleForTesting private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)? ) : ChooserActivity(), MediaProjectionAppSelectorView, MediaProjectionAppSelectorResultHandler, LifecycleOwner { @Inject constructor( componentFactory: MediaProjectionAppSelectorComponent.Factory, activityLauncher: AsyncActivityLauncher ) : this(componentFactory, activityLauncher, listControllerFactory = null) private val lifecycleRegistry = LifecycleRegistry(this) override val lifecycle = lifecycleRegistry private lateinit var configurationController: ConfigurationController private lateinit var controller: MediaProjectionAppSelectorController private lateinit var recentsViewController: MediaProjectionRecentsViewController private lateinit var component: MediaProjectionAppSelectorComponent // Indicate if we are under the media projection security flow // i.e. when a host app reuses consent token, review the permission and update it to the service private var reviewGrantedConsentRequired = false // If an app is selected, set to true so that we don't send RECORD_CANCEL in onDestroy private var taskSelected = false override fun getLayoutResource() = R.layout.media_projection_app_selector public override fun onCreate(bundle: Bundle?) { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) component = componentFactory.create(activity = this, view = this, resultHandler = this) component.lifecycleObservers.forEach { lifecycle.addObserver(it) } // Create a separate configuration controller for this activity as the configuration // might be different from the global one configurationController = component.configurationController controller = component.controller recentsViewController = component.recentsViewController intent.configureChooserIntent( resources, component.hostUserHandle, component.personalProfileUserHandle ) reviewGrantedConsentRequired = intent.getBooleanExtra(EXTRA_USER_REVIEW_GRANTED_CONSENT, false) super.onCreate(bundle) controller.init() // we override AppList's AccessibilityDelegate set in ResolverActivity.onCreate because in // our case this delegate must extend RecyclerViewAccessibilityDelegate, otherwise // RecyclerView scrolling is broken setAppListAccessibilityDelegate() } override fun onStart() { super.onStart() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) } override fun onResume() { super.onResume() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } override fun onPause() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) super.onPause() } override fun onStop() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) super.onStop() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) configurationController.onConfigurationChanged(newConfig) } override fun appliedThemeResId(): Int = R.style.Theme_SystemUI_MediaProjectionAppSelector override fun createBlockerEmptyStateProvider(): EmptyStateProvider = component.emptyStateProvider override fun createListController(userHandle: UserHandle): ResolverListController = listControllerFactory?.invoke(userHandle) ?: super.createListController(userHandle) override fun startSelected(which: Int, always: Boolean, filtered: Boolean) { val currentListAdapter = mChooserMultiProfilePagerAdapter.activeListAdapter val targetInfo = currentListAdapter.targetInfoForPosition(which, filtered) ?: return if (targetInfo is NotSelectableTargetInfo) return val intent = createIntent(targetInfo) val launchToken: IBinder = Binder("media_projection_launch_token") val activityOptions = ActivityOptions.makeBasic() activityOptions.launchCookie = launchToken val userHandle = mMultiProfilePagerAdapter.activeListAdapter.userHandle // Launch activity asynchronously and wait for the result, launching of an activity // is typically very fast, so we don't show any loaders. // We wait for the activity to be launched to make sure that the window of the activity // is created and ready to be captured. val activityStarted = activityLauncher.startActivityAsUser(intent, userHandle, activityOptions.toBundle()) { returnSelectedApp(launchToken) } // Rely on the ActivityManager to pop up a dialog regarding app suspension // and return false if suspended if (!targetInfo.isSuspended && activityStarted) { // TODO(b/222078415) track activity launch } } private fun createIntent(target: TargetInfo): Intent { val intent = Intent(target.resolvedIntent) // Launch the app in a new task, so it won't be in the host's app task intent.flags = intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK // Remove activity forward result flag as this activity will // return the media projection session intent.flags = intent.flags and Intent.FLAG_ACTIVITY_FORWARD_RESULT.inv() return intent } override fun onDestroy() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) component.lifecycleObservers.forEach { lifecycle.removeObserver(it) } // onDestroy is also called when an app is selected, in that case we only want to send // RECORD_CONTENT_TASK but not RECORD_CANCEL if (!taskSelected) { // TODO(b/272010156): Return result to PermissionActivity and update service there MediaProjectionServiceHelper.setReviewedConsentIfNeeded( RECORD_CANCEL, reviewGrantedConsentRequired, /* projection= */ null ) } activityLauncher.destroy() controller.destroy() super.onDestroy() } override fun onActivityStarted(cti: TargetInfo) { // do nothing } override fun bind(recentTasks: List) { recentsViewController.bind(recentTasks) if (!hasWorkProfile()) { // Make sure to refresh the adapter, to show/hide the recents view depending on whether // there are recents or not. mMultiProfilePagerAdapter.personalListAdapter.notifyDataSetChanged() } } override fun returnSelectedApp(launchCookie: IBinder) { taskSelected = true if (intent.hasExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER)) { // The client requested to return the result in the result receiver instead of // activity result, let's send the media projection to the result receiver val resultReceiver = intent.getParcelableExtra( EXTRA_CAPTURE_REGION_RESULT_RECEIVER, ResultReceiver::class.java ) as ResultReceiver val captureRegion = MediaProjectionCaptureTarget(launchCookie) val data = Bundle().apply { putParcelable(KEY_CAPTURE_TARGET, captureRegion) } resultReceiver.send(RESULT_OK, data) // TODO(b/279175710): Ensure consent result is always set here. Skipping this for now // in ScreenMediaRecorder, since we know the permission grant (projection) is never // reused in that scenario. } else { // TODO(b/272010156): Return result to PermissionActivity and update service there // Return the media projection instance as activity result val mediaProjectionBinder = intent.getIBinderExtra(EXTRA_MEDIA_PROJECTION) val projection = IMediaProjection.Stub.asInterface(mediaProjectionBinder) projection.launchCookie = launchCookie val intent = Intent() intent.putExtra(EXTRA_MEDIA_PROJECTION, projection.asBinder()) setResult(RESULT_OK, intent) setForceSendResultForMediaProjection() MediaProjectionServiceHelper.setReviewedConsentIfNeeded( RECORD_CONTENT_TASK, reviewGrantedConsentRequired, projection ) } finish() } override fun shouldGetOnlyDefaultActivities() = false override fun shouldShowContentPreview() = if (hasWorkProfile()) { // When the user has a work profile, we can always set this to true, and the layout is // adjusted automatically, and hide the recents view. true } else { // When there is no work profile, we should only show the content preview if there are // recents, otherwise the collapsed app selector will look empty. recentsViewController.hasRecentTasks } override fun shouldShowStickyContentPreviewWhenEmpty() = shouldShowContentPreview() override fun shouldShowServiceTargets() = false private fun hasWorkProfile() = mMultiProfilePagerAdapter.count > 1 override fun createMyUserIdProvider(): MyUserIdProvider = object : MyUserIdProvider() { override fun getMyUserId(): Int = component.hostUserHandle.identifier } override fun createContentPreviewView(parent: ViewGroup): ViewGroup = recentsViewController.createView(parent) companion object { const val TAG = "MediaProjectionAppSelectorActivity" /** * When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra the activity will * send the [CaptureRegion] to the result receiver instead of returning media projection * instance through activity result. */ const val EXTRA_CAPTURE_REGION_RESULT_RECEIVER = "capture_region_result_receiver" /** UID of the app that originally launched the media projection flow (host app user) */ const val EXTRA_HOST_APP_USER_HANDLE = "launched_from_user_handle" const val KEY_CAPTURE_TARGET = "capture_region" /** Set up intent for the [ChooserActivity] */ private fun Intent.configureChooserIntent( resources: Resources, hostUserHandle: UserHandle, personalProfileUserHandle: UserHandle ) { // Specify the query intent to show icons for all apps on the chooser screen val queryIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) } putExtra(Intent.EXTRA_INTENT, queryIntent) // Update the title of the chooser val title = resources.getString(R.string.screen_share_permission_app_selector_title) putExtra(Intent.EXTRA_TITLE, title) // Select host app's profile tab by default val selectedProfile = if (hostUserHandle == personalProfileUserHandle) { PROFILE_PERSONAL } else { PROFILE_WORK } putExtra(EXTRA_SELECTED_PROFILE, selectedProfile) } } private fun setAppListAccessibilityDelegate() { val rdl = requireViewById(com.android.internal.R.id.contentPanel) for (i in 0 until mMultiProfilePagerAdapter.count) { val list = mMultiProfilePagerAdapter .getItem(i) .rootView .findViewById(com.android.internal.R.id.resolver_list) if (list == null || list !is RecyclerView) { Log.wtf(TAG, "MediaProjection only supports RecyclerView") } else { list.accessibilityDelegate = RecyclerViewExpandingAccessibilityDelegate(rdl, list) } } } /** * An a11y delegate propagating all a11y events to [AppListAccessibilityDelegate] so that it can * expand drawer when needed. It needs to extend [RecyclerViewAccessibilityDelegate] because * that superclass handles RecyclerView scrolling while using a11y services. */ private class RecyclerViewExpandingAccessibilityDelegate( rdl: ResolverDrawerLayout, view: RecyclerView ) : RecyclerViewAccessibilityDelegate(view) { private val delegate = AppListAccessibilityDelegate(rdl) override fun onRequestSendAccessibilityEvent( host: ViewGroup, child: View, event: AccessibilityEvent ): Boolean { super.onRequestSendAccessibilityEvent(host, child, event) return delegate.onRequestSendAccessibilityEvent(host, child, event) } } }