Desktop tile that is a snapshot of desktop

Create a desktop recents tile that shows a snapshot of the freeform
tasks running on desktop.
Scales them down and positions them in the same location as they would
be on the desktop.

Bug: 244348395
Test: manual
Change-Id: Ieb5830a331691844769003189f557c4b7e4cd35c
diff --git a/quickstep/res/layout/task_desktop.xml b/quickstep/res/layout/task_desktop.xml
new file mode 100644
index 0000000..0c8543f
--- /dev/null
+++ b/quickstep/res/layout/task_desktop.xml
@@ -0,0 +1,46 @@
+<?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.
+-->
+
+<com.android.quickstep.views.DesktopTaskView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:clipChildren="true"
+    android:clipToOutline="true"
+    android:defaultFocusHighlightEnabled="false"
+    android:focusable="true">
+
+    <!--
+         TODO(b249371338): DesktopTaskView extends from TaskView. TaskView expects TaskThumbnailView
+         and IconView with these ids to be present. Need to refactor RecentsView to accept child
+         views that do not inherint from TaskView only or create a generic TaskView that have
+         N number of tasks.
+     -->
+    <com.android.quickstep.views.TaskThumbnailView
+        android:id="@+id/snapshot"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+
+    <com.android.quickstep.views.IconView
+        android:id="@+id/icon"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:focusable="false"
+        android:importantForAccessibility="no"
+        android:visibility="gone" />
+
+</com.android.quickstep.views.DesktopTaskView>
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index 9106a8f..7e5201c 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -1829,6 +1829,7 @@
     }
 
     private void finishCurrentTransitionToRecents() {
+        // TODO(b/245569277#comment2): enable once isFreeformActive is implemented
         mStateCallback.setStateOnUiThread(STATE_CURRENT_TASK_FINISHED);
         if (mRecentsAnimationController != null) {
             mRecentsAnimationController.detachNavigationBarFromApp(true);
diff --git a/quickstep/src/com/android/quickstep/RecentTasksList.java b/quickstep/src/com/android/quickstep/RecentTasksList.java
index 6b616b1..7bcc661 100644
--- a/quickstep/src/com/android/quickstep/RecentTasksList.java
+++ b/quickstep/src/com/android/quickstep/RecentTasksList.java
@@ -17,6 +17,8 @@
 package com.android.quickstep;
 
 import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR;
+import static com.android.quickstep.views.DesktopTaskView.DESKTOP_MODE_SUPPORTED;
+import static com.android.wm.shell.util.GroupedRecentTaskInfo.TYPE_FREEFORM;
 
 import android.annotation.TargetApi;
 import android.app.ActivityManager;
@@ -30,6 +32,7 @@
 
 import com.android.launcher3.util.LooperExecutor;
 import com.android.launcher3.util.SplitConfigurationOptions;
+import com.android.quickstep.util.DesktopTask;
 import com.android.quickstep.util.GroupTask;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.wm.shell.recents.IRecentTasksListener;
@@ -253,8 +256,9 @@
         };
 
         TaskLoadResult allTasks = new TaskLoadResult(requestId, loadKeysOnly, rawTasks.size());
+
         for (GroupedRecentTaskInfo rawTask : rawTasks) {
-            if (rawTask.getType() == GroupedRecentTaskInfo.TYPE_FREEFORM) {
+            if (DESKTOP_MODE_SUPPORTED && rawTask.getType() == TYPE_FREEFORM) {
                 GroupTask desktopTask = createDesktopTask(rawTask);
                 allTasks.add(desktopTask);
                 continue;
@@ -284,14 +288,18 @@
         return allTasks;
     }
 
-    private GroupTask createDesktopTask(GroupedRecentTaskInfo taskInfo) {
-        // TODO(b/244348395): create a subclass of GroupTask for desktop tile
-        // We need a single task information as the primary task. Use the first task
-        Task.TaskKey key = new Task.TaskKey(taskInfo.getTaskInfo1());
-        Task task = new Task(key);
-        task.desktopTile = true;
-        task.topActivity = key.sourceComponent;
-        return new GroupTask(task, null, null);
+    private DesktopTask createDesktopTask(GroupedRecentTaskInfo recentTaskInfo) {
+        ArrayList<Task> tasks = new ArrayList<>(recentTaskInfo.getTaskInfoList().size());
+        for (ActivityManager.RecentTaskInfo taskInfo : recentTaskInfo.getTaskInfoList()) {
+            Task.TaskKey key = new Task.TaskKey(taskInfo);
+            Task task = Task.from(key, taskInfo, false);
+            task.setLastSnapshotData(taskInfo);
+            task.positionInParent = taskInfo.positionInParent;
+            task.appBounds = taskInfo.configuration.windowConfiguration.getAppBounds();
+            // TODO(b/244348395): tasks should be sorted from oldest to most recently used
+            tasks.add(task);
+        }
+        return new DesktopTask(tasks);
     }
 
     private SplitConfigurationOptions.SplitBounds convertSplitBounds(
@@ -306,7 +314,7 @@
     private ArrayList<GroupTask> copyOf(ArrayList<GroupTask> tasks) {
         ArrayList<GroupTask> newTasks = new ArrayList<>();
         for (int i = 0; i < tasks.size(); i++) {
-            newTasks.add(new GroupTask(tasks.get(i)));
+            newTasks.add(tasks.get(i).copy());
         }
         return newTasks;
     }
diff --git a/quickstep/src/com/android/quickstep/TaskViewUtils.java b/quickstep/src/com/android/quickstep/TaskViewUtils.java
index 7d3d8a2..df80e2f 100644
--- a/quickstep/src/com/android/quickstep/TaskViewUtils.java
+++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java
@@ -302,9 +302,15 @@
             // to follow the TaskViewSimulator. So the final matrix applied on the thumbnailView is:
             //    Mt K(0)` K(t) Mt`
             TaskThumbnailView[] thumbnails = v.getThumbnails();
-            Matrix[] mt = new Matrix[simulatorCopies.length];
-            Matrix[] mti = new Matrix[simulatorCopies.length];
-            for (int i = 0; i < thumbnails.length; i++) {
+
+            // In case simulator copies and thumbnail size do no match, ensure we get the lesser.
+            // This ensures we do not create arrays with empty elements or attempt to references
+            // indexes out of array bounds.
+            final int matrixSize = Math.min(simulatorCopies.length, thumbnails.length);
+
+            Matrix[] mt = new Matrix[matrixSize];
+            Matrix[] mti = new Matrix[matrixSize];
+            for (int i = 0; i < matrixSize; i++) {
                 TaskThumbnailView ttv = thumbnails[i];
                 RectF localBounds = new RectF(0, 0,  ttv.getWidth(), ttv.getHeight());
                 float[] tvBoundsMapped = new float[]{0, 0,  ttv.getWidth(), ttv.getHeight()};
@@ -321,14 +327,14 @@
                 mti[i] = localMti;
             }
 
-            Matrix[] k0i = new Matrix[simulatorCopies.length];
-            for (int i = 0; i < simulatorCopies.length; i++) {
+            Matrix[] k0i = new Matrix[matrixSize];
+            for (int i = 0; i < matrixSize; i++) {
                 k0i[i] = new Matrix();
                 simulatorCopies[i].getTaskViewSimulator().getCurrentMatrix().invert(k0i[i]);
             }
             Matrix animationMatrix = new Matrix();
             out.addOnFrameCallback(() -> {
-                for (int i = 0; i < simulatorCopies.length; i++) {
+                for (int i = 0; i < matrixSize; i++) {
                     animationMatrix.set(mt[i]);
                     animationMatrix.postConcat(k0i[i]);
                     animationMatrix.postConcat(simulatorCopies[i]
diff --git a/quickstep/src/com/android/quickstep/util/DesktopTask.java b/quickstep/src/com/android/quickstep/util/DesktopTask.java
new file mode 100644
index 0000000..433d23f
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/DesktopTask.java
@@ -0,0 +1,56 @@
+/*
+ * 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.quickstep.util;
+
+import com.android.quickstep.views.TaskView;
+import com.android.systemui.shared.recents.model.Task;
+
+import java.util.ArrayList;
+
+/**
+ * A {@link Task} container that can contain N number of tasks that are part of the desktop in
+ * recent tasks list.
+ */
+public class DesktopTask extends GroupTask {
+
+    public ArrayList<Task> tasks;
+
+    public DesktopTask(ArrayList<Task> tasks) {
+        super(tasks.get(0), null, null, TaskView.Type.DESKTOP);
+        this.tasks = tasks;
+    }
+
+    @Override
+    public boolean containsTask(int taskId) {
+        for (Task task : tasks) {
+            if (task.key.id == taskId) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public boolean hasMultipleTasks() {
+        return true;
+    }
+
+    @Override
+    public DesktopTask copy() {
+        return new DesktopTask(tasks);
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/GroupTask.java b/quickstep/src/com/android/quickstep/util/GroupTask.java
index f30d00c..2be4f0a 100644
--- a/quickstep/src/com/android/quickstep/util/GroupTask.java
+++ b/quickstep/src/com/android/quickstep/util/GroupTask.java
@@ -20,6 +20,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.launcher3.util.SplitConfigurationOptions.SplitBounds;
+import com.android.quickstep.views.TaskView;
 import com.android.systemui.shared.recents.model.Task;
 
 /**
@@ -27,24 +28,25 @@
  * are represented as an app-pair in the recents task list.
  */
 public class GroupTask {
-    public @NonNull Task task1;
-    public @Nullable Task task2;
-    public @Nullable
-    SplitBounds mSplitBounds;
+    @NonNull
+    public final Task task1;
+    @Nullable
+    public final Task task2;
+    @Nullable
+    public final SplitBounds mSplitBounds;
+    @TaskView.Type
+    public final int taskViewType;
 
-    public GroupTask(@NonNull Task t1, @Nullable Task t2,
-            @Nullable SplitBounds splitBounds) {
+    public GroupTask(@NonNull Task t1, @Nullable Task t2, @Nullable SplitBounds splitBounds) {
+        this(t1, t2, splitBounds, t2 != null ? TaskView.Type.GROUPED : TaskView.Type.SINGLE);
+    }
+
+    protected GroupTask(@NonNull Task t1, @Nullable Task t2, @Nullable SplitBounds splitBounds,
+            @TaskView.Type int taskViewType) {
         task1 = t1;
         task2 = t2;
         mSplitBounds = splitBounds;
-    }
-
-    public GroupTask(@NonNull GroupTask group) {
-        task1 = new Task(group.task1);
-        task2 = group.task2 != null
-                ? new Task(group.task2)
-                : null;
-        mSplitBounds = group.mSplitBounds;
+        this.taskViewType = taskViewType;
     }
 
     public boolean containsTask(int taskId) {
@@ -54,4 +56,14 @@
     public boolean hasMultipleTasks() {
         return task2 != null;
     }
+
+    /**
+     * Create a copy of this instance
+     */
+    public GroupTask copy() {
+        return new GroupTask(
+                new Task(task1),
+                task2 != null ? new Task(task2) : null,
+                mSplitBounds);
+    }
 }
diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.java b/quickstep/src/com/android/quickstep/views/DesktopTaskView.java
new file mode 100644
index 0000000..9874f96
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.java
@@ -0,0 +1,467 @@
+/*
+ * 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.quickstep.views;
+
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.RoundRectShape;
+import android.os.SystemProperties;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.Utilities;
+import com.android.launcher3.touch.PagedOrientationHandler;
+import com.android.launcher3.util.RunnableList;
+import com.android.quickstep.RecentsModel;
+import com.android.quickstep.SystemUiProxy;
+import com.android.quickstep.TaskThumbnailCache;
+import com.android.quickstep.util.CancellableTask;
+import com.android.quickstep.util.RecentsOrientedState;
+import com.android.systemui.shared.recents.model.Task;
+import com.android.systemui.shared.recents.model.ThumbnailData;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * TaskView that contains all tasks that are part of the desktop.
+ */
+// TODO(b/249371338): TaskView needs to be refactored to have better support for N tasks.
+public class DesktopTaskView extends TaskView {
+
+    /** Flag to indicate whether desktop mode is available on the device */
+    public static final boolean DESKTOP_MODE_SUPPORTED = SystemProperties.getBoolean(
+            "persist.wm.debug.desktop_mode", false);
+
+    private static final String TAG = DesktopTaskView.class.getSimpleName();
+
+    private static final boolean DEBUG = true;
+
+    private List<Task> mTasks;
+
+    private final ArrayList<TaskThumbnailView> mSnapshotViews = new ArrayList<>();
+
+    /** Maps {@code taskIds} to corresponding {@link TaskThumbnailView}s */
+    private final SparseArray<TaskThumbnailView> mSnapshotViewMap = new SparseArray<>();
+
+    private final ArrayList<CancellableTask<?>> mPendingThumbnailRequests = new ArrayList<>();
+
+    public DesktopTaskView(Context context) {
+        this(context, null);
+    }
+
+    public DesktopTaskView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public DesktopTaskView(Context context, AttributeSet attrs, int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+    }
+
+    @Override
+    protected void onFinishInflate() {
+        super.onFinishInflate();
+        float[] outerRadii = new float[8];
+        Arrays.fill(outerRadii, getTaskCornerRadius());
+        RoundRectShape shape = new RoundRectShape(outerRadii, null, null);
+        ShapeDrawable background = new ShapeDrawable(shape);
+        background.setTint(getResources().getColor(android.R.color.system_neutral2_300));
+        // TODO(b/244348395): this should be wallpaper
+        setBackground(background);
+
+        mSnapshotViews.add(mSnapshotView);
+    }
+
+    @Override
+    public void bind(Task task, RecentsOrientedState orientedState) {
+        bind(Collections.singletonList(task), orientedState);
+    }
+
+    /**
+     * Updates this desktop task to the gives task list defined in {@code tasks}
+     */
+    public void bind(List<Task> tasks, RecentsOrientedState orientedState) {
+        if (DEBUG) {
+            StringBuilder sb = new StringBuilder();
+            sb.append("bind tasks=").append(tasks.size()).append("\n");
+            for (Task task : tasks) {
+                sb.append(" key=").append(task.key).append("\n");
+            }
+            Log.d(TAG, sb.toString());
+        }
+        if (tasks.isEmpty()) {
+            return;
+        }
+        cancelPendingLoadTasks();
+
+        mTasks = tasks;
+        mSnapshotViewMap.clear();
+
+        // Ensure there are equal number of snapshot views and tasks.
+        // More tasks than views, add views. More views than tasks, remove views.
+        // TODO(b/251586230): use a ViewPool for creating TaskThumbnailViews
+        if (mSnapshotViews.size() > mTasks.size()) {
+            int diff = mSnapshotViews.size() - mTasks.size();
+            for (int i = 0; i < diff; i++) {
+                TaskThumbnailView snapshotView = mSnapshotViews.remove(0);
+                removeView(snapshotView);
+            }
+        } else if (mSnapshotViews.size() < mTasks.size()) {
+            int diff = mTasks.size() - mSnapshotViews.size();
+            for (int i = 0; i < diff; i++) {
+                TaskThumbnailView snapshotView = new TaskThumbnailView(getContext());
+                mSnapshotViews.add(snapshotView);
+                addView(snapshotView, new LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+            }
+        }
+
+        for (int i = 0; i < mTasks.size(); i++) {
+            Task task = mTasks.get(i);
+            TaskThumbnailView snapshotView = mSnapshotViews.get(i);
+            snapshotView.bind(task);
+            mSnapshotViewMap.put(task.key.id, snapshotView);
+        }
+
+        updateTaskIdContainer();
+        updateTaskIdAttributeContainer();
+
+        setOrientationState(orientedState);
+    }
+
+    private void updateTaskIdContainer() {
+        // TODO(b/249371338): TaskView expects the array to have at least 2 elements.
+        // At least 2 elements in the array
+        mTaskIdContainer = new int[Math.max(mTasks.size(), 2)];
+        for (int i = 0; i < mTasks.size(); i++) {
+            mTaskIdContainer[i] = mTasks.get(i).key.id;
+        }
+    }
+
+    private void updateTaskIdAttributeContainer() {
+        // TODO(b/249371338): TaskView expects the array to have at least 2 elements.
+        // At least 2 elements in the array
+        mTaskIdAttributeContainer = new TaskIdAttributeContainer[Math.max(mTasks.size(), 2)];
+        for (int i = 0; i < mTasks.size(); i++) {
+            Task task = mTasks.get(i);
+            TaskThumbnailView thumbnailView = mSnapshotViewMap.get(task.key.id);
+            mTaskIdAttributeContainer[i] = createAttributeContainer(task, thumbnailView);
+        }
+    }
+
+    private TaskIdAttributeContainer createAttributeContainer(Task task,
+            TaskThumbnailView thumbnailView) {
+        return new TaskIdAttributeContainer(task, thumbnailView, null, STAGE_POSITION_UNDEFINED);
+    }
+
+    @Nullable
+    @Override
+    public Task getTask() {
+        // TODO(b/249371338): returning first task. This won't work well with multiple tasks.
+        return mTasks.size() > 0 ? mTasks.get(0) : null;
+    }
+
+    @Override
+    public TaskThumbnailView getThumbnail() {
+        // TODO(b/249371338): returning single thumbnail. This won't work well with multiple tasks.
+        Task task = getTask();
+        if (task != null) {
+            return mSnapshotViewMap.get(task.key.id);
+        }
+        return null;
+    }
+
+    @Override
+    public boolean containsTaskId(int taskId) {
+        // Thumbnail map contains taskId -> thumbnail map. Use the keys for contains
+        return mSnapshotViewMap.contains(taskId);
+    }
+
+    @Override
+    public void onTaskListVisibilityChanged(boolean visible, int changes) {
+        cancelPendingLoadTasks();
+        if (visible) {
+            RecentsModel model = RecentsModel.INSTANCE.get(getContext());
+            TaskThumbnailCache thumbnailCache = model.getThumbnailCache();
+
+            if (needsUpdate(changes, FLAG_UPDATE_THUMBNAIL)) {
+                for (Task task : mTasks) {
+                    CancellableTask<?> thumbLoadRequest =
+                            thumbnailCache.updateThumbnailInBackground(task, thumbnailData -> {
+                                TaskThumbnailView thumbnailView = mSnapshotViewMap.get(task.key.id);
+                                if (thumbnailView != null) {
+                                    thumbnailView.setThumbnail(task, thumbnailData);
+                                }
+                            });
+                    if (thumbLoadRequest != null) {
+                        mPendingThumbnailRequests.add(thumbLoadRequest);
+                    }
+                }
+            }
+        } else {
+            if (needsUpdate(changes, FLAG_UPDATE_THUMBNAIL)) {
+                for (Task task : mTasks) {
+                    TaskThumbnailView thumbnailView = mSnapshotViewMap.get(task.key.id);
+                    if (thumbnailView != null) {
+                        thumbnailView.setThumbnail(null, null);
+                    }
+                    // Reset the task thumbnail ref
+                    task.thumbnail = null;
+                }
+            }
+        }
+    }
+
+    @Override
+    public void setOrientationState(RecentsOrientedState orientationState) {
+        // TODO(b/249371338): this copies logic from TaskView
+        PagedOrientationHandler orientationHandler = orientationState.getOrientationHandler();
+        boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
+        DeviceProfile deviceProfile = mActivity.getDeviceProfile();
+
+        LayoutParams iconParams = (LayoutParams) mIconView.getLayoutParams();
+
+        int thumbnailTopMargin = deviceProfile.overviewTaskThumbnailTopMarginPx;
+        int taskIconHeight = deviceProfile.overviewTaskIconSizePx;
+        int taskMargin = deviceProfile.overviewTaskMarginPx;
+
+        orientationHandler.setTaskIconParams(iconParams, taskMargin, taskIconHeight,
+                thumbnailTopMargin, isRtl);
+
+        LayoutParams snapshotParams = (LayoutParams) mSnapshotView.getLayoutParams();
+        snapshotParams.topMargin = thumbnailTopMargin;
+
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            TaskThumbnailView thumbnailView = mSnapshotViewMap.valueAt(i);
+            thumbnailView.setLayoutParams(snapshotParams);
+        }
+    }
+
+    @Override
+    protected void cancelPendingLoadTasks() {
+        for (CancellableTask<?> cancellableTask : mPendingThumbnailRequests) {
+            cancellableTask.cancel();
+        }
+        mPendingThumbnailRequests.clear();
+    }
+
+    @Override
+    public boolean offerTouchToChildren(MotionEvent event) {
+        return false;
+    }
+
+    @Override
+    protected boolean showTaskMenuWithContainer(IconView iconView) {
+        return false;
+    }
+
+    @Nullable
+    @Override
+    public RunnableList launchTaskAnimated() {
+        RunnableList endCallback = new RunnableList();
+        SystemUiProxy.INSTANCE.get(getContext()).showDesktopApps();
+        RecentsView<?, ?> recentsView = getRecentsView();
+        recentsView.addSideTaskLaunchCallback(endCallback);
+        return endCallback;
+    }
+
+    @Override
+    public void launchTask(@NonNull Consumer<Boolean> callback, boolean freezeTaskList) {
+        SystemUiProxy.INSTANCE.get(getContext()).showDesktopApps();
+        callback.accept(true);
+    }
+
+    @Override
+    void refreshThumbnails(@Nullable HashMap<Integer, ThumbnailData> thumbnailDatas) {
+        // Sets new thumbnails based on the incoming data and refreshes the rest.
+        // Create a copy of the thumbnail map, so we can track thumbnails that need refreshing.
+        SparseArray<TaskThumbnailView> thumbnailsToRefresh = mSnapshotViewMap.clone();
+        if (thumbnailDatas != null) {
+            for (Task task : mTasks) {
+                int key = task.key.id;
+                TaskThumbnailView thumbnailView = thumbnailsToRefresh.get(key);
+                ThumbnailData thumbnailData = thumbnailDatas.get(key);
+                if (thumbnailView != null && thumbnailData != null) {
+                    thumbnailView.setThumbnail(task, thumbnailData);
+                    // Remove this thumbnail from the list that should be refreshed.
+                    thumbnailsToRefresh.remove(key);
+                }
+            }
+        }
+
+        // Refresh the rest that were not updated.
+        for (int i = 0; i < thumbnailsToRefresh.size(); i++) {
+            thumbnailsToRefresh.valueAt(i).refresh();
+        }
+    }
+
+    @Override
+    public TaskThumbnailView[] getThumbnails() {
+        TaskThumbnailView[] thumbnails = new TaskThumbnailView[mSnapshotViewMap.size()];
+        for (int i = 0; i < thumbnails.length; i++) {
+            thumbnails[i] = mSnapshotViewMap.valueAt(i);
+        }
+        return thumbnails;
+    }
+
+    @Override
+    public void onRecycle() {
+        resetPersistentViewTransforms();
+        // Clear any references to the thumbnail (it will be re-read either from the cache or the
+        // system on next bind)
+        for (Task task : mTasks) {
+            TaskThumbnailView thumbnailView = mSnapshotViewMap.get(task.key.id);
+            if (thumbnailView != null) {
+                thumbnailView.setThumbnail(task, null);
+            }
+        }
+        setOverlayEnabled(false);
+        onTaskListVisibilityChanged(false);
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+        int containerWidth = MeasureSpec.getSize(widthMeasureSpec);
+        int containerHeight = MeasureSpec.getSize(heightMeasureSpec);
+
+        setMeasuredDimension(containerWidth, containerHeight);
+
+        int thumbnails = mSnapshotViewMap.size();
+        if (thumbnails == 0) {
+            return;
+        }
+
+        int windowWidth = mActivity.getDeviceProfile().widthPx;
+        int windowHeight = mActivity.getDeviceProfile().heightPx;
+
+        float scaleWidth = containerWidth / (float) windowWidth;
+        float scaleHeight = containerHeight / (float) windowHeight;
+
+        if (DEBUG) {
+            Log.d(TAG,
+                    "onMeasure: container=[" + containerWidth + "," + containerHeight + "] window=["
+                            + windowWidth + "," + windowHeight + "] scale=[" + scaleWidth + ","
+                            + scaleHeight + "]");
+        }
+
+        // Desktop tile is a shrunk down version of launcher and freeform task thumbnails.
+        for (int i = 0; i < mTasks.size(); i++) {
+            Task task = mTasks.get(i);
+            Rect taskSize = task.appBounds;
+            if (taskSize == null) {
+                // Default to quarter of the desktop if we did not get app bounds.
+                taskSize = new Rect(0, 0, windowWidth / 4, windowHeight / 4);
+            }
+
+            int thumbWidth = (int) (taskSize.width() * scaleWidth);
+            int thumbHeight = (int) (taskSize.height() * scaleHeight);
+
+            TaskThumbnailView thumbnailView = mSnapshotViewMap.get(task.key.id);
+            if (thumbnailView != null) {
+                thumbnailView.measure(MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
+                        MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY));
+
+                // Position the task to the same position as it would be on the desktop
+                Point positionInParent = task.positionInParent;
+                if (positionInParent == null) {
+                    positionInParent = new Point(0, 0);
+                }
+                int taskX = (int) (positionInParent.x * scaleWidth);
+                int taskY = (int) (positionInParent.y * scaleHeight);
+                thumbnailView.setX(taskX);
+                thumbnailView.setY(taskY);
+
+                if (DEBUG) {
+                    Log.d(TAG, "onMeasure: task=" + task.key + " thumb=[" + thumbWidth + ","
+                            + thumbHeight + "]" + " pos=[" + taskX + "," + taskY + "]");
+                }
+            }
+        }
+    }
+
+    @Override
+    public void setOverlayEnabled(boolean overlayEnabled) {
+        // Intentional no-op to prevent setting smart actions overlay on thumbnails
+    }
+
+    @Override
+    public void setFullscreenProgress(float progress) {
+        // TODO(b/249371338): this copies parent implementation and makes it work for N thumbs
+        progress = Utilities.boundToRange(progress, 0, 1);
+        mFullscreenProgress = progress;
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            TaskThumbnailView thumbnailView = mSnapshotViewMap.valueAt(i);
+            thumbnailView.getTaskOverlay().setFullscreenProgress(progress);
+            updateSnapshotRadius();
+        }
+    }
+
+    @Override
+    protected void updateSnapshotRadius() {
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            mSnapshotViewMap.valueAt(i).setFullscreenParams(mCurrentFullscreenParams);
+        }
+    }
+
+    @Override
+    protected void setIconAndDimTransitionProgress(float progress, boolean invert) {
+        // no-op
+    }
+
+    @Override
+    public void setColorTint(float amount, int tintColor) {
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            mSnapshotViewMap.valueAt(i).setDimAlpha(amount);
+        }
+    }
+
+    @Override
+    protected void applyThumbnailSplashAlpha() {
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            mSnapshotViewMap.valueAt(i).setSplashAlpha(mTaskThumbnailSplashAlpha);
+        }
+    }
+
+    @Override
+    void setThumbnailVisibility(int visibility) {
+        for (int i = 0; i < mSnapshotViewMap.size(); i++) {
+            mSnapshotViewMap.valueAt(i).setVisibility(visibility);
+        }
+    }
+
+    @Override
+    protected boolean confirmSecondSplitSelectApp() {
+        // Desktop tile can't be in split screen
+        return false;
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
index 3a5f606..71b0c60 100644
--- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
+++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java
@@ -225,6 +225,12 @@
     }
 
     @Override
+    public boolean containsTaskId(int taskId) {
+        return (mTask != null && mTask.key.id == taskId)
+                || (mSecondaryTask != null && mSecondaryTask.key.id == taskId);
+    }
+
+    @Override
     public TaskThumbnailView[] getThumbnails() {
         return new TaskThumbnailView[]{mSnapshotView, mSnapshotView2};
     }
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index 0e0acf0..eeaf238 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -54,6 +54,7 @@
 import static com.android.launcher3.util.SystemUiController.UI_STATE_FULLSCREEN_TASK;
 import static com.android.quickstep.TaskUtils.checkCurrentOrManagedUserId;
 import static com.android.quickstep.views.ClearAllButton.DISMISS_ALPHA;
+import static com.android.quickstep.views.DesktopTaskView.DESKTOP_MODE_SUPPORTED;
 import static com.android.quickstep.views.OverviewActionsView.FLAG_IS_NOT_TABLET;
 import static com.android.quickstep.views.OverviewActionsView.FLAG_SINGLE_TASK;
 import static com.android.quickstep.views.OverviewActionsView.HIDDEN_NON_ZERO_ROTATION;
@@ -72,6 +73,7 @@
 import android.animation.ValueAnimator;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
+import android.app.WindowConfiguration;
 import android.content.Context;
 import android.content.LocusId;
 import android.content.res.Configuration;
@@ -171,6 +173,7 @@
 import com.android.quickstep.util.ActiveGestureErrorDetector;
 import com.android.quickstep.util.ActiveGestureLog;
 import com.android.quickstep.util.AnimUtils;
+import com.android.quickstep.util.DesktopTask;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.LayoutUtils;
 import com.android.quickstep.util.RecentsOrientedState;
@@ -195,6 +198,7 @@
 import com.android.wm.shell.pip.IPipAnimationListener;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Objects;
@@ -477,10 +481,11 @@
     private final InvariantDeviceProfile mIdp;
 
     /**
-     * Getting views should be done via {@link #getTaskViewFromPool(boolean)}
+     * Getting views should be done via {@link #getTaskViewFromPool(int)}
      */
     private final ViewPool<TaskView> mTaskViewPool;
     private final ViewPool<GroupedTaskView> mGroupedTaskViewPool;
+    private final ViewPool<DesktopTaskView> mDesktopTaskViewPool;
 
     private final TaskOverlayFactory mTaskOverlayFactory;
 
@@ -737,6 +742,8 @@
                 10 /* initial size */);
         mGroupedTaskViewPool = new ViewPool<>(context, this,
                 R.layout.task_grouped, 20 /* max size */, 10 /* initial size */);
+        mDesktopTaskViewPool = new ViewPool<>(context, this, R.layout.task_desktop,
+                5 /* max size */, 1 /* initial size */);
 
         mIsRtl = mOrientationHandler.getRecentsRtlSetting(getResources());
         setLayoutDirection(mIsRtl ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR);
@@ -981,6 +988,8 @@
             }
             if (child instanceof GroupedTaskView) {
                 mGroupedTaskViewPool.recycle((GroupedTaskView) taskView);
+            } else if (child instanceof DesktopTaskView) {
+                mDesktopTaskViewPool.recycle((DesktopTaskView) taskView);
             } else {
                 mTaskViewPool.recycle(taskView);
             }
@@ -1199,8 +1208,7 @@
 
         for (int i = 0; i < getTaskViewCount(); i++) {
             TaskView taskView = requireTaskViewAt(i);
-            int[] taskIds = taskView.getTaskIds();
-            if (taskIds[0] == taskId || taskIds[1] == taskId) {
+            if (taskView.containsTaskId(taskId)) {
                 return taskView;
             }
         }
@@ -1481,21 +1489,24 @@
         // Add views as children based on whether it's grouped or single task
         for (int i = taskGroups.size() - 1; i >= 0; i--) {
             GroupTask groupTask = taskGroups.get(i);
-            boolean hasMultipleTasks = groupTask.hasMultipleTasks();
-            TaskView taskView = getTaskViewFromPool(hasMultipleTasks);
+            TaskView taskView = getTaskViewFromPool(groupTask.taskViewType);
             addView(taskView);
 
-            if (hasMultipleTasks) {
+            if (taskView instanceof GroupedTaskView) {
                 boolean firstTaskIsLeftTopTask =
                         groupTask.mSplitBounds.leftTopTaskId == groupTask.task1.key.id;
                 Task leftTopTask = firstTaskIsLeftTopTask ? groupTask.task1 : groupTask.task2;
                 Task rightBottomTask = firstTaskIsLeftTopTask ? groupTask.task2 : groupTask.task1;
                 ((GroupedTaskView) taskView).bind(leftTopTask, rightBottomTask, mOrientationState,
                         groupTask.mSplitBounds);
+            } else if (taskView instanceof DesktopTaskView) {
+                ((DesktopTaskView) taskView).bind(((DesktopTask) groupTask).tasks,
+                        mOrientationState);
             } else {
                 taskView.bind(groupTask.task1, mOrientationState);
             }
         }
+
         if (!taskGroups.isEmpty()) {
             addView(mClearAllButton);
         }
@@ -2123,10 +2134,19 @@
      * Handle the edge case where Recents could increment task count very high over long
      * period of device usage. Probably will never happen, but meh.
      */
-    private <T extends TaskView> T getTaskViewFromPool(boolean isGrouped) {
-        T taskView = isGrouped ?
-                (T) mGroupedTaskViewPool.getView() :
-                (T) mTaskViewPool.getView();
+    private TaskView getTaskViewFromPool(@TaskView.Type int type) {
+        TaskView taskView;
+        switch (type) {
+            case TaskView.Type.GROUPED:
+                taskView = mGroupedTaskViewPool.getView();
+                break;
+            case TaskView.Type.DESKTOP:
+                taskView = mDesktopTaskViewPool.getView();
+                break;
+            case TaskView.Type.SINGLE:
+            default:
+                taskView = mTaskViewPool.getView();
+        }
         taskView.setTaskViewId(mTaskViewIdCount);
         if (mTaskViewIdCount == Integer.MAX_VALUE) {
             mTaskViewIdCount = 0;
@@ -2318,12 +2338,19 @@
         }
         int runningTaskViewId = -1;
         boolean needGroupTaskView = runningTasks.length > 1;
+        boolean needDesktopTask = hasDesktopTask(runningTasks);
         if (shouldAddStubTaskView(runningTasks)) {
             boolean wasEmpty = getChildCount() == 0;
             // Add an empty view for now until the task plan is loaded and applied
             final TaskView taskView;
-            if (needGroupTaskView) {
-                taskView = getTaskViewFromPool(true);
+            if (needDesktopTask) {
+                taskView = getTaskViewFromPool(TaskView.Type.DESKTOP);
+                mTmpRunningTasks = Arrays.copyOf(runningTasks, runningTasks.length);
+                addView(taskView, 0);
+                ((DesktopTaskView) taskView).bind(Arrays.asList(mTmpRunningTasks),
+                        mOrientationState);
+            } else if (needGroupTaskView) {
+                taskView = getTaskViewFromPool(TaskView.Type.GROUPED);
                 mTmpRunningTasks = new Task[]{runningTasks[0], runningTasks[1]};
                 addView(taskView, 0);
                 // When we create a placeholder task view mSplitBoundsConfig will be null, but with
@@ -2332,7 +2359,7 @@
                 ((GroupedTaskView)taskView).bind(mTmpRunningTasks[0], mTmpRunningTasks[1],
                         mOrientationState, mSplitBoundsConfig);
             } else {
-                taskView = getTaskViewFromPool(false);
+                taskView = getTaskViewFromPool(TaskView.Type.SINGLE);
                 addView(taskView, 0);
                 // The temporary running task is only used for the duration between the start of the
                 // gesture and the task list is loaded and applied
@@ -2367,6 +2394,18 @@
         reloadIfNeeded();
     }
 
+    private boolean hasDesktopTask(Task[] runningTasks) {
+        if (!DESKTOP_MODE_SUPPORTED) {
+            return false;
+        }
+        for (Task task : runningTasks) {
+            if (task.key.windowingMode == WindowConfiguration.WINDOWING_MODE_FREEFORM) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Sets the running task id, cleaning up the old running task if necessary.
      */
diff --git a/quickstep/src/com/android/quickstep/views/TaskView.java b/quickstep/src/com/android/quickstep/views/TaskView.java
index 0125775..db607f0 100644
--- a/quickstep/src/com/android/quickstep/views/TaskView.java
+++ b/quickstep/src/com/android/quickstep/views/TaskView.java
@@ -87,7 +87,6 @@
 import com.android.quickstep.RecentsModel;
 import com.android.quickstep.RemoteAnimationTargets;
 import com.android.quickstep.RemoteTargetGluer.RemoteTargetHandle;
-import com.android.quickstep.SystemUiProxy;
 import com.android.quickstep.TaskIconCache;
 import com.android.quickstep.TaskOverlayFactory;
 import com.android.quickstep.TaskThumbnailCache;
@@ -136,6 +135,17 @@
     @IntDef({FLAG_UPDATE_ALL, FLAG_UPDATE_ICON, FLAG_UPDATE_THUMBNAIL})
     public @interface TaskDataChanges {}
 
+    /**
+     * Type of task view
+     */
+    @Retention(SOURCE)
+    @IntDef({Type.SINGLE, Type.GROUPED, Type.DESKTOP})
+    public @interface Type {
+        int SINGLE = 1;
+        int GROUPED = 2;
+        int DESKTOP = 3;
+    }
+
     /** The maximum amount that a task view can be scrimmed, dimmed or tinted. */
     public static final float MAX_PAGE_SCRIM_ALPHA = 0.4f;
 
@@ -332,7 +342,7 @@
     protected TaskThumbnailView mSnapshotView;
     protected IconView mIconView;
     protected final DigitalWellBeingToast mDigitalWellBeingToast;
-    private float mFullscreenProgress;
+    protected float mFullscreenProgress;
     private float mGridProgress;
     protected float mTaskThumbnailSplashAlpha;
     private float mNonGridScale = 1;
@@ -374,8 +384,8 @@
     /**
      * Index 0 will contain taskID of left/top task, index 1 will contain taskId of bottom/right
      */
-    protected final int[] mTaskIdContainer = new int[]{-1, -1};
-    protected final TaskIdAttributeContainer[] mTaskIdAttributeContainer =
+    protected int[] mTaskIdContainer = new int[]{-1, -1};
+    protected TaskIdAttributeContainer[] mTaskIdAttributeContainer =
             new TaskIdAttributeContainer[2];
 
     private boolean mShowScreenshot;
@@ -526,6 +536,13 @@
     }
 
     /**
+     * Check if given {@code taskId} is tracked in this view
+     */
+    public boolean containsTaskId(int taskId) {
+        return mTask != null && mTask.key.id == taskId;
+    }
+
+    /**
      * @return integer array of two elements to be size consistent with max number of tasks possible
      *         index 0 will contain the taskId, index 1 will be -1 indicating a null taskID value
      */
@@ -565,13 +582,13 @@
     @Override
     public boolean dispatchTouchEvent(MotionEvent ev) {
         RecentsView recentsView = getRecentsView();
-        if (recentsView == null || mTask == null) {
+        if (recentsView == null || getTask() == null) {
             return false;
         }
         SplitSelectStateController splitSelectStateController =
                 recentsView.getSplitSelectController();
         if (splitSelectStateController.isSplitSelectActive() &&
-                splitSelectStateController.getInitialTaskId() == mTask.key.id) {
+                splitSelectStateController.getInitialTaskId() == getTask().key.id) {
             // Prevent taps on the this taskview if it's being animated into split select state
             return false;
         }
@@ -598,11 +615,14 @@
      * @return {@code true} if user is already in split select mode and this tap was to choose the
      *         second app. {@code false} otherwise
      */
-    private boolean confirmSecondSplitSelectApp() {
+    protected boolean confirmSecondSplitSelectApp() {
         int index = getLastSelectedChildTaskIndex();
         TaskIdAttributeContainer container = mTaskIdAttributeContainer[index];
-        return getRecentsView().confirmSplitSelect(this, container.getTask(),
-                container.getIconView(), container.getThumbnailView());
+        if (container != null) {
+            return getRecentsView().confirmSplitSelect(this, container.getTask(),
+                    container.getIconView(), container.getThumbnailView());
+        }
+        return false;
     }
 
     /**
@@ -710,11 +730,6 @@
         RecentsView recentsView = getRecentsView();
         RemoteTargetHandle[] remoteTargetHandles = recentsView.mRemoteTargetHandles;
         RunnableList runnableList = new RunnableList();
-        if (mTask != null && mTask.desktopTile) {
-            // clicked on desktop
-            SystemUiProxy.INSTANCE.get(getContext()).showDesktopApps();
-            return runnableList;
-        }
         if (isRunningTask() && remoteTargetHandles != null) {
             if (!mIsClickableAsLiveTile) {
                 return runnableList;