blob: cf64a838c2bf153e6d86077aa93b67bc13ed2f12 [file] [log] [blame]
/*
* 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<RecentTask>) {
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<ResolverDrawerLayout>(com.android.internal.R.id.contentPanel)
for (i in 0 until mMultiProfilePagerAdapter.count) {
val list =
mMultiProfilePagerAdapter
.getItem(i)
.rootView
.findViewById<View>(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)
}
}
}