summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Nick Chameyev <nickchameyev@google.com> 2022-09-23 14:11:03 +0000
committer Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> 2022-09-23 14:11:03 +0000
commitd84bec201ce133d43f351b69d306ee75843568fa (patch)
tree1889cacba850bb0b84de0c6a9301d9a656ee8e63
parent32b7c278ba9088a795c041644b9cc46c482e6df4 (diff)
parent66e91066196bb441f65f45f9c26b8134a91ffc81 (diff)
Merge "[Partial Screensharing] Add initial recents selector UI" into tm-qpr-dev am: 66e9106619
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/19867406 Change-Id: I4f6b2799fa1009f3c3b888ac926c8c7455cfbc28 Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
-rw-r--r--core/java/com/android/internal/app/ChooserActivity.java22
-rw-r--r--packages/SystemUI/res/layout/media_projection_app_selector.xml5
-rw-r--r--packages/SystemUI/res/layout/media_projection_recent_tasks.xml54
-rw-r--r--packages/SystemUI/res/layout/media_projection_task_item.xml38
-rw-r--r--packages/SystemUI/res/values/dimens.xml2
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt91
-rw-r--r--packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt58
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt49
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorView.kt23
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/AppIconLoader.kt41
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTask.kt26
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskListProvider.kt38
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskThumbnailLoader.kt41
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt78
-rw-r--r--packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt64
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/recycler/HorizontalSpacerItemDecoration.kt44
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt119
17 files changed, 769 insertions, 24 deletions
diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java
index 84557b7909c2..f5ba275b604b 100644
--- a/core/java/com/android/internal/app/ChooserActivity.java
+++ b/core/java/com/android/internal/app/ChooserActivity.java
@@ -1069,7 +1069,12 @@ public class ChooserActivity extends ResolverActivity implements
}
}
- private ViewGroup createContentPreviewView(ViewGroup parent) {
+ /**
+ * Create a view that will be shown in the content preview area
+ * @param parent reference to the parent container where the view should be attached to
+ * @return content preview view
+ */
+ protected ViewGroup createContentPreviewView(ViewGroup parent) {
Intent targetIntent = getTargetIntent();
int previewType = findPreferredContentPreview(targetIntent, getContentResolver());
return displayContentPreview(previewType, targetIntent, getLayoutInflater(), parent);
@@ -2653,7 +2658,7 @@ public class ChooserActivity extends ResolverActivity implements
boolean isExpandable = getResources().getConfiguration().orientation
== Configuration.ORIENTATION_PORTRAIT && !isInMultiWindowMode();
- if (directShareHeight != 0 && isSendAction(getTargetIntent())
+ if (directShareHeight != 0 && shouldShowContentPreview()
&& isExpandable) {
// make sure to leave room for direct share 4->8 expansion
int requiredExpansionHeight =
@@ -2901,7 +2906,14 @@ public class ChooserActivity extends ResolverActivity implements
return shouldShowTabs()
&& mMultiProfilePagerAdapter.getListAdapterForUserHandle(
UserHandle.of(UserHandle.myUserId())).getCount() > 0
- && isSendAction(getTargetIntent());
+ && shouldShowContentPreview();
+ }
+
+ /**
+ * @return true if we want to show the content preview area
+ */
+ protected boolean shouldShowContentPreview() {
+ return isSendAction(getTargetIntent());
}
private void updateStickyContentPreview() {
@@ -3234,7 +3246,7 @@ public class ChooserActivity extends ResolverActivity implements
return 0;
}
- if (!isSendAction(getTargetIntent())) {
+ if (!shouldShowContentPreview()) {
return 0;
}
@@ -3265,7 +3277,7 @@ public class ChooserActivity extends ResolverActivity implements
// There can be at most one row in the listview, that is internally
// a ViewGroup with 2 rows
public int getServiceTargetRowCount() {
- if (isSendAction(getTargetIntent())
+ if (shouldShowContentPreview()
&& !ActivityManager.isLowRamDeviceStatic()) {
return 1;
}
diff --git a/packages/SystemUI/res/layout/media_projection_app_selector.xml b/packages/SystemUI/res/layout/media_projection_app_selector.xml
index 4ad6849e9c45..226bc6afc5ab 100644
--- a/packages/SystemUI/res/layout/media_projection_app_selector.xml
+++ b/packages/SystemUI/res/layout/media_projection_app_selector.xml
@@ -36,13 +36,14 @@
android:background="@*android:drawable/bottomsheet_background">
<ImageView
- android:id="@*android:id/icon"
android:layout_width="@dimen/media_projection_app_selector_icon_size"
android:layout_height="@dimen/media_projection_app_selector_icon_size"
android:layout_marginTop="@*android:dimen/chooser_edge_margin_normal"
android:layout_marginBottom="@*android:dimen/chooser_edge_margin_normal"
android:importantForAccessibility="no"
- android:tint="?android:attr/textColorPrimary"/>
+ android:tint="?android:attr/textColorPrimary"
+ android:src="@drawable/ic_present_to_all"
+ />
<TextView android:id="@*android:id/title"
android:layout_height="wrap_content"
diff --git a/packages/SystemUI/res/layout/media_projection_recent_tasks.xml b/packages/SystemUI/res/layout/media_projection_recent_tasks.xml
new file mode 100644
index 000000000000..a2b3c404f077
--- /dev/null
+++ b/packages/SystemUI/res/layout/media_projection_recent_tasks.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ 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.
+ -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:orientation="vertical"
+ android:background="?android:attr/colorBackground"
+ >
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="256dp">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/media_projection_recent_tasks_recycler"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ />
+
+ <ProgressBar
+ android:id="@+id/media_projection_recent_tasks_loader"
+ android:layout_width="@dimen/media_projection_app_selector_loader_size"
+ android:layout_height="@dimen/media_projection_app_selector_loader_size"
+ android:layout_gravity="center"
+ android:indeterminate="true"
+ android:indeterminateOnly="true" />
+ </FrameLayout>
+
+ <!-- Divider line -->
+ <ImageView
+ android:layout_width="72dp"
+ android:layout_height="2dp"
+ android:layout_marginBottom="8dp"
+ android:layout_marginTop="24dp"
+ android:importantForAccessibility="no"
+ android:src="@*android:drawable/ic_drag_handle"
+ android:tint="?android:attr/textColorSecondary" />
+</LinearLayout>
diff --git a/packages/SystemUI/res/layout/media_projection_task_item.xml b/packages/SystemUI/res/layout/media_projection_task_item.xml
new file mode 100644
index 000000000000..75f73cd7a1e9
--- /dev/null
+++ b/packages/SystemUI/res/layout/media_projection_task_item.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ ~ 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.
+ -->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:gravity="center"
+ android:orientation="vertical">
+
+ <ImageView
+ android:id="@+id/task_icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_margin="8dp"
+ android:importantForAccessibility="no" />
+
+ <!-- TODO(b/240924926) use a custom view that will handle thumbnail cropping correctly -->
+ <!-- TODO(b/240924926) dynamically change the view size based on the screen size -->
+ <ImageView
+ android:id="@+id/task_thumbnail"
+ android:layout_width="100dp"
+ android:layout_height="216dp"
+ android:scaleType="centerCrop"
+ />
+</LinearLayout>
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index eb6c45747924..e09a6ee91fc6 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1454,6 +1454,8 @@
<dimen name="fgs_manager_list_top_spacing">12dp</dimen>
<dimen name="media_projection_app_selector_icon_size">32dp</dimen>
+ <dimen name="media_projection_app_selector_recents_padding">16dp</dimen>
+ <dimen name="media_projection_app_selector_loader_size">32dp</dimen>
<!-- Dream overlay related dimensions -->
<dimen name="dream_overlay_status_bar_height">60dp</dimen>
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt b/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt
index 0f1ee31e066e..c6bd777fbd7a 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaProjectionAppSelectorActivity.kt
@@ -24,22 +24,45 @@ import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
import android.os.UserHandle
-import android.widget.ImageView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.internal.annotations.VisibleForTesting
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.annotations.VisibleForTesting;
-import com.android.systemui.util.AsyncActivityLauncher
import com.android.systemui.R
-import com.android.internal.R as AndroidR
+import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorController
+import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorView
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+import com.android.systemui.mediaprojection.appselector.view.RecentTasksAdapter
+import com.android.systemui.mediaprojection.appselector.view.RecentTasksAdapter.RecentTaskClickListener
+import com.android.systemui.util.AsyncActivityLauncher
+import com.android.systemui.util.recycler.HorizontalSpacerItemDecoration
+import javax.inject.Inject
-class MediaProjectionAppSelectorActivity constructor(
+class MediaProjectionAppSelectorActivity(
private val activityLauncher: AsyncActivityLauncher,
+ private val controller: MediaProjectionAppSelectorController,
+ private val recentTasksAdapterFactory: RecentTasksAdapter.Factory,
/** This is used to override the dependency in a screenshot test */
@VisibleForTesting
- private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)? = null
-) : ChooserActivity() {
+ private val listControllerFactory: ((userHandle: UserHandle) -> ResolverListController)?
+) : ChooserActivity(), MediaProjectionAppSelectorView, RecentTaskClickListener {
+
+ @Inject
+ constructor(
+ activityLauncher: AsyncActivityLauncher,
+ controller: MediaProjectionAppSelectorController,
+ recentTasksAdapterFactory: RecentTasksAdapter.Factory,
+ ) : this(activityLauncher, controller, recentTasksAdapterFactory, null)
+
+ private var recentsRoot: ViewGroup? = null
+ private var recentsProgress: View? = null
+ private var recentsRecycler: RecyclerView? = null
override fun getLayoutResource() =
R.layout.media_projection_app_selector
@@ -52,10 +75,30 @@ class MediaProjectionAppSelectorActivity constructor(
// TODO(b/240939253): update copies
val title = getString(R.string.media_projection_dialog_service_title)
intent.putExtra(Intent.EXTRA_TITLE, title)
-
super.onCreate(bundle)
+ controller.init(this)
+ }
- requireViewById<ImageView>(AndroidR.id.icon).setImageResource(R.drawable.ic_present_to_all)
+ private fun createRecentsView(parent: ViewGroup): ViewGroup {
+ val recentsRoot = LayoutInflater.from(this)
+ .inflate(R.layout.media_projection_recent_tasks, parent,
+ /* attachToRoot= */ false) as ViewGroup
+
+ recentsProgress = recentsRoot.requireViewById(R.id.media_projection_recent_tasks_loader)
+ recentsRecycler = recentsRoot.requireViewById(R.id.media_projection_recent_tasks_recycler)
+ recentsRecycler?.layoutManager = LinearLayoutManager(
+ this, LinearLayoutManager.HORIZONTAL,
+ /* reverseLayout= */false
+ )
+
+ val itemDecoration = HorizontalSpacerItemDecoration(
+ resources.getDimensionPixelOffset(
+ R.dimen.media_projection_app_selector_recents_padding
+ )
+ )
+ recentsRecycler?.addItemDecoration(itemDecoration)
+
+ return recentsRoot
}
override fun appliedThemeResId(): Int =
@@ -108,6 +151,7 @@ class MediaProjectionAppSelectorActivity constructor(
override fun onDestroy() {
activityLauncher.destroy()
+ controller.destroy()
super.onDestroy()
}
@@ -115,6 +159,27 @@ class MediaProjectionAppSelectorActivity constructor(
// do nothing
}
+ override fun bind(recentTasks: List<RecentTask>) {
+ val recents = recentsRoot ?: return
+ val progress = recentsProgress ?: return
+ val recycler = recentsRecycler ?: return
+
+ if (recentTasks.isEmpty()) {
+ recents.visibility = View.GONE
+ return
+ }
+
+ progress.visibility = View.GONE
+ recycler.visibility = View.VISIBLE
+ recents.visibility = View.VISIBLE
+
+ recycler.adapter = recentTasksAdapterFactory.create(recentTasks, this)
+ }
+
+ override fun onRecentClicked(task: RecentTask, view: View) {
+ // TODO(b/240924732) Handle clicking on a recent task
+ }
+
private fun onTargetActivityLaunched(launchToken: IBinder) {
if (intent.hasExtra(EXTRA_CAPTURE_REGION_RESULT_RECEIVER)) {
// The client requested to return the result in the result receiver instead of
@@ -145,6 +210,14 @@ class MediaProjectionAppSelectorActivity constructor(
override fun shouldGetOnlyDefaultActivities() = false
+ // TODO(b/240924732) flip the flag when the recents selector is ready
+ override fun shouldShowContentPreview() = false
+
+ override fun createContentPreviewView(parent: ViewGroup): ViewGroup =
+ recentsRoot ?: createRecentsView(parent).also {
+ recentsRoot = it
+ }
+
companion object {
/**
* When EXTRA_CAPTURE_REGION_RESULT_RECEIVER is passed as intent extra
diff --git a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt
index 969699834024..185b4fca87d0 100644
--- a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaProjectionModule.kt
@@ -17,13 +17,29 @@
package com.android.systemui.media.dagger
import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.media.MediaProjectionAppSelectorActivity
-import com.android.systemui.util.AsyncActivityLauncher
+import com.android.systemui.mediaprojection.appselector.MediaProjectionAppSelectorController
+import com.android.systemui.mediaprojection.appselector.data.ActivityTaskManagerThumbnailLoader
+import com.android.systemui.mediaprojection.appselector.data.AppIconLoader
+import com.android.systemui.mediaprojection.appselector.data.IconLoaderLibAppIconLoader
+import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider
+import com.android.systemui.mediaprojection.appselector.data.RecentTaskThumbnailLoader
+import com.android.systemui.mediaprojection.appselector.data.ShellRecentTaskListProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class MediaProjectionAppSelector
@Module
abstract class MediaProjectionModule {
@@ -31,17 +47,43 @@ abstract class MediaProjectionModule {
@Binds
@IntoMap
@ClassKey(MediaProjectionAppSelectorActivity::class)
- abstract fun bindMediaProjectionAppSelectorActivity(
- activity: MediaProjectionAppSelectorActivity): Activity
+ abstract fun provideMediaProjectionAppSelectorActivity(
+ activity: MediaProjectionAppSelectorActivity
+ ): Activity
+
+ @Binds
+ abstract fun bindRecentTaskThumbnailLoader(
+ impl: ActivityTaskManagerThumbnailLoader
+ ): RecentTaskThumbnailLoader
+
+ @Binds
+ abstract fun bindRecentTaskListProvider(
+ impl: ShellRecentTaskListProvider
+ ): RecentTaskListProvider
+
+ @Binds
+ abstract fun bindAppIconLoader(impl: IconLoaderLibAppIconLoader): AppIconLoader
companion object {
@Provides
- fun provideMediaProjectionAppSelectorActivity(
- activityLauncher: AsyncActivityLauncher
- ): MediaProjectionAppSelectorActivity {
- return MediaProjectionAppSelectorActivity(
- activityLauncher
+ fun provideController(
+ recentTaskListProvider: RecentTaskListProvider,
+ context: Context,
+ @MediaProjectionAppSelector scope: CoroutineScope
+ ): MediaProjectionAppSelectorController {
+ val appSelectorComponentName =
+ ComponentName(context, MediaProjectionAppSelectorActivity::class.java)
+
+ return MediaProjectionAppSelectorController(
+ recentTaskListProvider,
+ scope,
+ appSelectorComponentName
)
}
+
+ @MediaProjectionAppSelector
+ @Provides
+ fun provideCoroutineScope(@Application applicationScope: CoroutineScope): CoroutineScope =
+ CoroutineScope(applicationScope.coroutineContext + SupervisorJob())
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt
new file mode 100644
index 000000000000..59c6635ed8bc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorController.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.mediaprojection.appselector
+
+import android.content.ComponentName
+import com.android.systemui.media.dagger.MediaProjectionAppSelector
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+class MediaProjectionAppSelectorController(
+ private val recentTaskListProvider: RecentTaskListProvider,
+ @MediaProjectionAppSelector private val scope: CoroutineScope,
+ private val appSelectorComponentName: ComponentName
+) {
+
+ fun init(view: MediaProjectionAppSelectorView) {
+ scope.launch {
+ val tasks = recentTaskListProvider.loadRecentTasks().sortTasks()
+ view.bind(tasks)
+ }
+ }
+
+ fun destroy() {
+ scope.cancel()
+ }
+
+ private fun List<RecentTask>.sortTasks(): List<RecentTask> =
+ asReversed().sortedBy {
+ // Show normal tasks first and only then tasks with opened app selector
+ it.topActivityComponent == appSelectorComponentName
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorView.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorView.kt
new file mode 100644
index 000000000000..6550aa5569e4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorView.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.mediaprojection.appselector
+
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+
+interface MediaProjectionAppSelectorView {
+ fun bind(recentTasks: List<RecentTask>)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/AppIconLoader.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/AppIconLoader.kt
new file mode 100644
index 000000000000..0bdddfe2821d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/AppIconLoader.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.mediaprojection.appselector.data
+
+import android.content.ComponentName
+import android.graphics.drawable.Drawable
+import com.android.systemui.dagger.qualifiers.Background
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+interface AppIconLoader {
+ suspend fun loadIcon(userId: Int, component: ComponentName): Drawable?
+}
+
+class IconLoaderLibAppIconLoader
+@Inject
+constructor(
+ @Background private val backgroundDispatcher: CoroutineDispatcher,
+) : AppIconLoader {
+
+ override suspend fun loadIcon(userId: Int, component: ComponentName): Drawable? =
+ withContext(backgroundDispatcher) {
+ // TODO(b/240924731): add a blocking call to load an icon using iconloaderlib
+ null
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTask.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTask.kt
new file mode 100644
index 000000000000..6d67e28e15e4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTask.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.mediaprojection.appselector.data
+
+import android.content.ComponentName
+
+data class RecentTask(
+ val taskId: Int,
+ val userId: Int,
+ val topActivityComponent: ComponentName?,
+ val baseIntentComponent: ComponentName?
+)
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskListProvider.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskListProvider.kt
new file mode 100644
index 000000000000..5a0943556d9d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskListProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.mediaprojection.appselector.data
+
+import com.android.systemui.dagger.qualifiers.Background
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+interface RecentTaskListProvider {
+ suspend fun loadRecentTasks(): List<RecentTask>
+}
+
+class ShellRecentTaskListProvider
+@Inject
+constructor(@Background private val coroutineDispatcher: CoroutineDispatcher) :
+ RecentTaskListProvider {
+
+ override suspend fun loadRecentTasks(): List<RecentTask> =
+ withContext(coroutineDispatcher) {
+ // TODO(b/240924731): add blocking call to load the recents
+ emptyList()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskThumbnailLoader.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskThumbnailLoader.kt
new file mode 100644
index 000000000000..429128032f40
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/data/RecentTaskThumbnailLoader.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.mediaprojection.appselector.data
+
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.shared.recents.model.ThumbnailData
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+
+interface RecentTaskThumbnailLoader {
+ suspend fun loadThumbnail(taskId: Int): ThumbnailData?
+}
+
+class ActivityTaskManagerThumbnailLoader
+@Inject
+constructor(
+ @Background private val coroutineDispatcher: CoroutineDispatcher,
+) :
+ RecentTaskThumbnailLoader {
+
+ override suspend fun loadThumbnail(taskId: Int): ThumbnailData? =
+ withContext(coroutineDispatcher) {
+ // TODO(b/240924731): add blocking call to load a thumbnail
+ null
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt
new file mode 100644
index 000000000000..ec5abc7a12f4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTaskViewHolder.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.mediaprojection.appselector.view
+
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.R
+import com.android.systemui.media.dagger.MediaProjectionAppSelector
+import com.android.systemui.mediaprojection.appselector.data.AppIconLoader
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+import com.android.systemui.mediaprojection.appselector.data.RecentTaskThumbnailLoader
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+
+class RecentTaskViewHolder @AssistedInject constructor(
+ @Assisted root: ViewGroup,
+ private val iconLoader: AppIconLoader,
+ private val thumbnailLoader: RecentTaskThumbnailLoader,
+ @MediaProjectionAppSelector private val scope: CoroutineScope
+) : RecyclerView.ViewHolder(root) {
+
+ private val iconView: ImageView = root.requireViewById(R.id.task_icon)
+ private val thumbnailView: ImageView = root.requireViewById(R.id.task_thumbnail)
+
+ private var job: Job? = null
+
+ fun bind(task: RecentTask, onClick: (View) -> Unit) {
+ job?.cancel()
+
+ job =
+ scope.launch {
+ task.baseIntentComponent?.let { component ->
+ launch {
+ val icon = iconLoader.loadIcon(task.userId, component)
+ iconView.setImageDrawable(icon)
+ }
+ }
+ launch {
+ val thumbnail = thumbnailLoader.loadThumbnail(task.taskId)
+ thumbnailView.setImageBitmap(thumbnail?.thumbnail)
+ }
+ }
+
+ thumbnailView.setOnClickListener(onClick)
+ }
+
+ fun onRecycled() {
+ iconView.setImageDrawable(null)
+ thumbnailView.setImageBitmap(null)
+ job?.cancel()
+ job = null
+ }
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(root: ViewGroup): RecentTaskViewHolder
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt
new file mode 100644
index 000000000000..ec9cfa88f34d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/mediaprojection/appselector/view/RecentTasksAdapter.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.mediaprojection.appselector.view
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.R
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+class RecentTasksAdapter @AssistedInject constructor(
+ @Assisted private val items: List<RecentTask>,
+ @Assisted private val listener: RecentTaskClickListener,
+ private val viewHolderFactory: RecentTaskViewHolder.Factory
+) : RecyclerView.Adapter<RecentTaskViewHolder>() {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentTaskViewHolder {
+ val taskItem =
+ LayoutInflater.from(parent.context)
+ .inflate(R.layout.media_projection_task_item, null) as ViewGroup
+
+ return viewHolderFactory.create(taskItem)
+ }
+
+ override fun onBindViewHolder(holder: RecentTaskViewHolder, position: Int) {
+ val task = items[position]
+ holder.bind(task, onClick = {
+ listener.onRecentClicked(task, holder.itemView)
+ })
+ }
+
+ override fun getItemCount(): Int = items.size
+
+ override fun onViewRecycled(holder: RecentTaskViewHolder) {
+ holder.onRecycled()
+ }
+
+ interface RecentTaskClickListener {
+ fun onRecentClicked(task: RecentTask, view: View)
+ }
+
+ @AssistedFactory
+ fun interface Factory {
+ fun create(items: List<RecentTask>, listener: RecentTaskClickListener): RecentTasksAdapter
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/util/recycler/HorizontalSpacerItemDecoration.kt b/packages/SystemUI/src/com/android/systemui/util/recycler/HorizontalSpacerItemDecoration.kt
new file mode 100644
index 000000000000..ac931e510139
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/recycler/HorizontalSpacerItemDecoration.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.util.recycler
+
+import android.graphics.Rect
+import android.view.View
+import androidx.annotation.Dimension
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * RecyclerView ItemDecorator that adds a horizontal space of the given size between items
+ * and double that space on the ends.
+ */
+class HorizontalSpacerItemDecoration(@Dimension private val offset: Int) :
+ RecyclerView.ItemDecoration() {
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ val position: Int = parent.getChildAdapterPosition(view)
+ val itemCount = parent.adapter?.itemCount ?: 0
+
+ val left = if (position == 0) offset * 2 else offset
+ val right = if (position == itemCount - 1) offset * 2 else offset
+
+ outRect.set(left, 0, right, 0)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt
new file mode 100644
index 000000000000..37b7f47fb69e
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/appselector/MediaProjectionAppSelectorControllerTest.kt
@@ -0,0 +1,119 @@
+package com.android.systemui.mediaprojection.appselector
+
+import android.content.ComponentName
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.mediaprojection.appselector.data.RecentTask
+import com.android.systemui.mediaprojection.appselector.data.RecentTaskListProvider
+import com.android.systemui.util.mockito.mock
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class MediaProjectionAppSelectorControllerTest : SysuiTestCase() {
+
+ private val taskListProvider = TestRecentTaskListProvider()
+ private val scope = CoroutineScope(Dispatchers.Unconfined)
+ private val appSelectorComponentName = ComponentName("com.test", "AppSelector")
+
+ private val view: MediaProjectionAppSelectorView = mock()
+
+ private val controller = MediaProjectionAppSelectorController(
+ taskListProvider,
+ scope,
+ appSelectorComponentName
+ )
+
+ @Test
+ fun initNoRecentTasks_bindsEmptyList() {
+ taskListProvider.tasks = emptyList()
+
+ controller.init(view)
+
+ verify(view).bind(emptyList())
+ }
+
+ @Test
+ fun initOneRecentTask_bindsList() {
+ taskListProvider.tasks = listOf(
+ createRecentTask(taskId = 1)
+ )
+
+ controller.init(view)
+
+ verify(view).bind(
+ listOf(
+ createRecentTask(taskId = 1)
+ )
+ )
+ }
+
+ @Test
+ fun initMultipleRecentTasksWithoutAppSelectorTask_bindsListInReverse() {
+ val tasks = listOf(
+ createRecentTask(taskId = 1),
+ createRecentTask(taskId = 2),
+ createRecentTask(taskId = 3),
+ )
+ taskListProvider.tasks = tasks
+
+ controller.init(view)
+
+ verify(view).bind(
+ listOf(
+ createRecentTask(taskId = 3),
+ createRecentTask(taskId = 2),
+ createRecentTask(taskId = 1),
+ )
+ )
+ }
+
+ @Test
+ fun initRecentTasksWithAppSelectorTasks_bindsListInReverseAndAppSelectorTasksAtTheEnd() {
+ val tasks = listOf(
+ createRecentTask(taskId = 1),
+ createRecentTask(taskId = 2, topActivityComponent = appSelectorComponentName),
+ createRecentTask(taskId = 3),
+ createRecentTask(taskId = 4, topActivityComponent = appSelectorComponentName),
+ createRecentTask(taskId = 5),
+ )
+ taskListProvider.tasks = tasks
+
+ controller.init(view)
+
+ verify(view).bind(
+ listOf(
+ createRecentTask(taskId = 5),
+ createRecentTask(taskId = 3),
+ createRecentTask(taskId = 1),
+ createRecentTask(taskId = 4, topActivityComponent = appSelectorComponentName),
+ createRecentTask(taskId = 2, topActivityComponent = appSelectorComponentName),
+ )
+ )
+ }
+
+ private fun createRecentTask(
+ taskId: Int,
+ topActivityComponent: ComponentName? = null
+ ): RecentTask {
+ return RecentTask(
+ taskId = taskId,
+ topActivityComponent = topActivityComponent,
+ baseIntentComponent = ComponentName("com", "Test"),
+ userId = 0
+ )
+ }
+
+ private class TestRecentTaskListProvider : RecentTaskListProvider {
+
+ var tasks: List<RecentTask> = emptyList()
+
+ override suspend fun loadRecentTasks(): List<RecentTask> = tasks
+
+ }
+}