Add shell recents interface

- Add a shell component and interface for Launcher to use to get recent
  tasks including paired splits (pending integration w/ split controller)

Bug: 202740477
Test: atest WMShellUnitTests:com.android.wm.shell.recents.RecentTasksControllerTest

Signed-off-by: Winson Chung <winsonc@google.com>
Change-Id: I34d834061ed2391d89cf56d6a2fd262868707674
Merged-In: I34d834061ed2391d89cf56d6a2fd262868707674
diff --git a/libs/WindowManager/Shell/Android.bp b/libs/WindowManager/Shell/Android.bp
index 3ba1a34..73c2e8b 100644
--- a/libs/WindowManager/Shell/Android.bp
+++ b/libs/WindowManager/Shell/Android.bp
@@ -38,6 +38,7 @@
     path: "src",
 }
 
+// Sources that have no dependencies that can be used directly downstream of this library
 filegroup {
     name: "wm_shell_util-sources",
     srcs: [
@@ -46,6 +47,7 @@
     path: "src",
 }
 
+// Aidls which can be used directly downstream of this library
 filegroup {
     name: "wm_shell-aidls",
     srcs: [
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellCommandHandlerImpl.java b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellCommandHandlerImpl.java
index e87b150..358553d7 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellCommandHandlerImpl.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellCommandHandlerImpl.java
@@ -24,6 +24,7 @@
 import com.android.wm.shell.legacysplitscreen.LegacySplitScreenController;
 import com.android.wm.shell.onehanded.OneHandedController;
 import com.android.wm.shell.pip.Pip;
+import com.android.wm.shell.recents.RecentTasksController;
 import com.android.wm.shell.splitscreen.SplitScreenController;
 
 import java.io.PrintWriter;
@@ -43,6 +44,7 @@
     private final Optional<OneHandedController> mOneHandedOptional;
     private final Optional<HideDisplayCutoutController> mHideDisplayCutout;
     private final Optional<AppPairsController> mAppPairsOptional;
+    private final Optional<RecentTasksController> mRecentTasks;
     private final ShellTaskOrganizer mShellTaskOrganizer;
     private final ShellExecutor mMainExecutor;
     private final HandlerImpl mImpl = new HandlerImpl();
@@ -55,8 +57,10 @@
             Optional<OneHandedController> oneHandedOptional,
             Optional<HideDisplayCutoutController> hideDisplayCutout,
             Optional<AppPairsController> appPairsOptional,
+            Optional<RecentTasksController> recentTasks,
             ShellExecutor mainExecutor) {
         mShellTaskOrganizer = shellTaskOrganizer;
+        mRecentTasks = recentTasks;
         mLegacySplitScreenOptional = legacySplitScreenOptional;
         mSplitScreenOptional = splitScreenOptional;
         mPipOptional = pipOptional;
@@ -85,6 +89,9 @@
         pw.println();
         pw.println();
         mSplitScreenOptional.ifPresent(splitScreen -> splitScreen.dump(pw, ""));
+        pw.println();
+        pw.println();
+        mRecentTasks.ifPresent(recentTasks -> recentTasks.dump(pw, ""));
     }
 
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellInitImpl.java b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellInitImpl.java
index fa58fcd..f567877 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellInitImpl.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellInitImpl.java
@@ -29,8 +29,8 @@
 import com.android.wm.shell.freeform.FreeformTaskListener;
 import com.android.wm.shell.fullscreen.FullscreenTaskListener;
 import com.android.wm.shell.fullscreen.FullscreenUnfoldController;
-import com.android.wm.shell.legacysplitscreen.LegacySplitScreenController;
 import com.android.wm.shell.pip.phone.PipTouchHandler;
+import com.android.wm.shell.recents.RecentTasksController;
 import com.android.wm.shell.splitscreen.SplitScreenController;
 import com.android.wm.shell.startingsurface.StartingWindowController;
 import com.android.wm.shell.transition.Transitions;
@@ -49,7 +49,6 @@
     private final DragAndDropController mDragAndDropController;
     private final ShellTaskOrganizer mShellTaskOrganizer;
     private final Optional<BubbleController> mBubblesOptional;
-    private final Optional<LegacySplitScreenController> mLegacySplitScreenOptional;
     private final Optional<SplitScreenController> mSplitScreenOptional;
     private final Optional<AppPairsController> mAppPairsOptional;
     private final Optional<PipTouchHandler> mPipTouchHandlerOptional;
@@ -59,6 +58,7 @@
     private final ShellExecutor mMainExecutor;
     private final Transitions mTransitions;
     private final StartingWindowController mStartingWindow;
+    private final Optional<RecentTasksController> mRecentTasks;
 
     private final InitImpl mImpl = new InitImpl();
 
@@ -69,13 +69,13 @@
             DragAndDropController dragAndDropController,
             ShellTaskOrganizer shellTaskOrganizer,
             Optional<BubbleController> bubblesOptional,
-            Optional<LegacySplitScreenController> legacySplitScreenOptional,
             Optional<SplitScreenController> splitScreenOptional,
             Optional<AppPairsController> appPairsOptional,
             Optional<PipTouchHandler> pipTouchHandlerOptional,
             FullscreenTaskListener fullscreenTaskListener,
             Optional<FullscreenUnfoldController> fullscreenUnfoldTransitionController,
             Optional<Optional<FreeformTaskListener>> freeformTaskListenerOptional,
+            Optional<RecentTasksController> recentTasks,
             Transitions transitions,
             StartingWindowController startingWindow,
             ShellExecutor mainExecutor) {
@@ -85,13 +85,13 @@
         mDragAndDropController = dragAndDropController;
         mShellTaskOrganizer = shellTaskOrganizer;
         mBubblesOptional = bubblesOptional;
-        mLegacySplitScreenOptional = legacySplitScreenOptional;
         mSplitScreenOptional = splitScreenOptional;
         mAppPairsOptional = appPairsOptional;
         mFullscreenTaskListener = fullscreenTaskListener;
         mPipTouchHandlerOptional = pipTouchHandlerOptional;
         mFullscreenUnfoldController = fullscreenUnfoldTransitionController;
         mFreeformTaskListenerOptional = freeformTaskListenerOptional.flatMap(f -> f);
+        mRecentTasks = recentTasks;
         mTransitions = transitions;
         mMainExecutor = mainExecutor;
         mStartingWindow = startingWindow;
@@ -135,6 +135,7 @@
                         f, ShellTaskOrganizer.TASK_LISTENER_TYPE_FREEFORM));
 
         mFullscreenUnfoldController.ifPresent(FullscreenUnfoldController::init);
+        mRecentTasks.ifPresent(RecentTasksController::init);
     }
 
     @ExternalThread
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellTaskOrganizer.java b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellTaskOrganizer.java
index 020ecb7..75bc461 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/ShellTaskOrganizer.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/ShellTaskOrganizer.java
@@ -51,6 +51,7 @@
 import com.android.internal.util.FrameworkStatsLog;
 import com.android.wm.shell.common.ScreenshotUtils;
 import com.android.wm.shell.common.ShellExecutor;
+import com.android.wm.shell.recents.RecentTasksController;
 import com.android.wm.shell.sizecompatui.SizeCompatUIController;
 import com.android.wm.shell.startingsurface.StartingWindowController;
 
@@ -59,6 +60,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.function.Consumer;
 
 /**
@@ -150,20 +152,34 @@
     @Nullable
     private final SizeCompatUIController mSizeCompatUI;
 
+    @Nullable
+    private final Optional<RecentTasksController> mRecentTasks;
+
     public ShellTaskOrganizer(ShellExecutor mainExecutor, Context context) {
-        this(null /* taskOrganizerController */, mainExecutor, context, null /* sizeCompatUI */);
+        this(null /* taskOrganizerController */, mainExecutor, context, null /* sizeCompatUI */,
+                Optional.empty() /* recentTasksController */);
     }
 
     public ShellTaskOrganizer(ShellExecutor mainExecutor, Context context, @Nullable
             SizeCompatUIController sizeCompatUI) {
-        this(null /* taskOrganizerController */, mainExecutor, context, sizeCompatUI);
+        this(null /* taskOrganizerController */, mainExecutor, context, sizeCompatUI,
+                Optional.empty() /* recentTasksController */);
+    }
+
+    public ShellTaskOrganizer(ShellExecutor mainExecutor, Context context, @Nullable
+            SizeCompatUIController sizeCompatUI,
+            Optional<RecentTasksController> recentTasks) {
+        this(null /* taskOrganizerController */, mainExecutor, context, sizeCompatUI,
+                recentTasks);
     }
 
     @VisibleForTesting
     ShellTaskOrganizer(ITaskOrganizerController taskOrganizerController, ShellExecutor mainExecutor,
-            Context context, @Nullable SizeCompatUIController sizeCompatUI) {
+            Context context, @Nullable SizeCompatUIController sizeCompatUI,
+            Optional<RecentTasksController> recentTasks) {
         super(taskOrganizerController, mainExecutor);
         mSizeCompatUI = sizeCompatUI;
+        mRecentTasks = recentTasks;
         if (sizeCompatUI != null) {
             sizeCompatUI.setSizeCompatUICallback(this);
         }
@@ -401,6 +417,11 @@
                 // Notify the size compat UI if the listener or task info changed.
                 notifySizeCompatUI(taskInfo, newListener);
             }
+            if (data.getTaskInfo().getWindowingMode() != taskInfo.getWindowingMode()) {
+                // Notify the recent tasks when a task changes windowing modes
+                mRecentTasks.ifPresent(recentTasks ->
+                        recentTasks.onTaskWindowingModeChanged(taskInfo));
+            }
         }
     }
 
@@ -428,6 +449,8 @@
             notifyLocusVisibilityIfNeeded(taskInfo);
             // Pass null for listener to remove the size compat UI on this task if there is any.
             notifySizeCompatUI(taskInfo, null /* taskListener */);
+            // Notify the recent tasks that a task has been removed
+            mRecentTasks.ifPresent(recentTasks -> recentTasks.onTaskRemoved(taskInfo));
         }
     }
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasks.aidl b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasks.aidl
new file mode 100644
index 0000000..6e78fcb
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasks.aidl
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2021 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.wm.shell.recents;
+
+import com.android.wm.shell.recents.IRecentTasksListener;
+import com.android.wm.shell.util.GroupedRecentTaskInfo;
+
+/**
+ * Interface that is exposed to remote callers to fetch recent tasks.
+ */
+interface IRecentTasks {
+
+    /**
+     * Registers a recent tasks listener.
+     */
+    oneway void registerRecentTasksListener(in IRecentTasksListener listener) = 1;
+
+    /**
+     * Unregisters a recent tasks listener.
+     */
+    oneway void unregisterRecentTasksListener(in IRecentTasksListener listener) = 2;
+
+    /**
+     * Gets the set of recent tasks.
+     */
+    GroupedRecentTaskInfo[] getRecentTasks(int maxNum, int flags, int userId) = 3;
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasksListener.aidl b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasksListener.aidl
new file mode 100644
index 0000000..8efa428
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/IRecentTasksListener.aidl
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2021 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 distshellributed 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.wm.shell.recents;
+
+/**
+ * Listener interface that Launcher attaches to SystemUI to get split-screen callbacks.
+ */
+oneway interface IRecentTasksListener {
+
+    /**
+     * Called when the set of recent tasks change.
+     */
+    void onRecentTasksChanged();
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasks.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasks.java
new file mode 100644
index 0000000..a5748f6
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasks.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.recents;
+
+import com.android.wm.shell.common.annotations.ExternalThread;
+
+/**
+ * Interface for interacting with the recent tasks.
+ */
+@ExternalThread
+public interface RecentTasks {
+    /**
+     * Returns a binder that can be passed to an external process to fetch recent tasks.
+     */
+    default IRecentTasks createExternalInterface() {
+        return null;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
new file mode 100644
index 0000000..836a6f6
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
@@ -0,0 +1,297 @@
+/*
+ * Copyright (C) 2021 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.wm.shell.recents;
+
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+
+import static com.android.wm.shell.common.ExecutorUtils.executeRemoteCallWithTaskPermission;
+
+import android.app.ActivityManager;
+import android.app.ActivityTaskManager;
+import android.app.TaskInfo;
+import android.content.Context;
+import android.os.RemoteException;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+
+import androidx.annotation.BinderThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.wm.shell.common.RemoteCallable;
+import com.android.wm.shell.common.ShellExecutor;
+import com.android.wm.shell.common.SingleInstanceRemoteListener;
+import com.android.wm.shell.common.TaskStackListenerCallback;
+import com.android.wm.shell.common.TaskStackListenerImpl;
+import com.android.wm.shell.common.annotations.ExternalThread;
+import com.android.wm.shell.common.annotations.ShellMainThread;
+import com.android.wm.shell.util.GroupedRecentTaskInfo;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages the recent task list from the system, caching it as necessary.
+ */
+public class RecentTasksController implements TaskStackListenerCallback,
+        RemoteCallable<RecentTasksController> {
+    private static final String TAG = RecentTasksController.class.getSimpleName();
+
+    private final Context mContext;
+    private final ShellExecutor mMainExecutor;
+    private final TaskStackListenerImpl mTaskStackListener;
+    private final RecentTasks mImpl = new RecentTasksImpl();
+
+    private final ArrayList<Runnable> mCallbacks = new ArrayList<>();
+    // Mapping of split task ids, mappings are symmetrical (ie. if t1 is the taskid of a task in a
+    // pair, then mSplitTasks[t1] = t2, and mSplitTasks[t2] = t1)
+    private final SparseIntArray mSplitTasks = new SparseIntArray();
+
+    /**
+     * Creates {@link RecentTasksController}, returns {@code null} if the feature is not
+     * supported.
+     */
+    @Nullable
+    public static RecentTasksController create(
+            Context context,
+            TaskStackListenerImpl taskStackListener,
+            @ShellMainThread ShellExecutor mainExecutor
+    ) {
+        if (!context.getResources().getBoolean(com.android.internal.R.bool.config_hasRecents)) {
+            return null;
+        }
+        return new RecentTasksController(context, taskStackListener, mainExecutor);
+    }
+
+    RecentTasksController(Context context, TaskStackListenerImpl taskStackListener,
+            ShellExecutor mainExecutor) {
+        mContext = context;
+        mTaskStackListener = taskStackListener;
+        mMainExecutor = mainExecutor;
+    }
+
+    public RecentTasks asRecentTasks() {
+        return mImpl;
+    }
+
+    public void init() {
+        mTaskStackListener.addListener(this);
+    }
+
+    /**
+     * Adds a split pair. This call does not validate the taskIds, only that they are not the same.
+     */
+    public void addSplitPair(int taskId1, int taskId2) {
+        if (taskId1 == taskId2) {
+            return;
+        }
+        // Remove any previous pairs
+        removeSplitPair(taskId1);
+        removeSplitPair(taskId2);
+        mSplitTasks.put(taskId1, taskId2);
+        mSplitTasks.put(taskId2, taskId1);
+    }
+
+    /**
+     * Removes a split pair.
+     */
+    public void removeSplitPair(int taskId) {
+        int pairedTaskId = mSplitTasks.get(taskId, INVALID_TASK_ID);
+        if (pairedTaskId != INVALID_TASK_ID) {
+            mSplitTasks.delete(taskId);
+            mSplitTasks.delete(pairedTaskId);
+        }
+    }
+
+    @Override
+    public Context getContext() {
+        return mContext;
+    }
+
+    @Override
+    public ShellExecutor getRemoteCallExecutor() {
+        return mMainExecutor;
+    }
+
+    @Override
+    public void onTaskStackChanged() {
+        notifyRecentTasksChanged();
+    }
+
+    @Override
+    public void onRecentTaskListUpdated() {
+        // In some cases immediately after booting, the tasks in the system recent task list may be
+        // loaded, but not in the active task hierarchy in the system.  These tasks are displayed in
+        // overview, but removing them don't result in a onTaskStackChanged() nor a onTaskRemoved()
+        // callback (those are for changes to the active tasks), but the task list is still updated,
+        // so we should also invalidate the change id to ensure we load a new list instead of
+        // reusing a stale list.
+        notifyRecentTasksChanged();
+    }
+
+    public void onTaskRemoved(TaskInfo taskInfo) {
+        // Remove any split pairs associated with this task
+        removeSplitPair(taskInfo.taskId);
+        notifyRecentTasksChanged();
+    }
+
+    public void onTaskWindowingModeChanged(TaskInfo taskInfo) {
+        notifyRecentTasksChanged();
+    }
+
+    @VisibleForTesting
+    void notifyRecentTasksChanged() {
+        for (int i = 0; i < mCallbacks.size(); i++) {
+            mCallbacks.get(i).run();
+        }
+    }
+
+    private void registerRecentTasksListener(Runnable listener) {
+        if (!mCallbacks.contains(listener)) {
+            mCallbacks.add(listener);
+        }
+    }
+
+    private void unregisterRecentTasksListener(Runnable listener) {
+        mCallbacks.remove(listener);
+    }
+
+    @VisibleForTesting
+    List<ActivityManager.RecentTaskInfo> getRawRecentTasks(int maxNum, int flags, int userId) {
+        return ActivityTaskManager.getInstance().getRecentTasks(maxNum, flags, userId);
+    }
+
+    @VisibleForTesting
+    ArrayList<GroupedRecentTaskInfo> getRecentTasks(int maxNum, int flags, int userId) {
+        // Note: the returned task list is from the most-recent to least-recent order
+        final List<ActivityManager.RecentTaskInfo> rawList = getRawRecentTasks(maxNum, flags,
+                userId);
+
+        // Make a mapping of task id -> task info
+        final SparseArray<ActivityManager.RecentTaskInfo> rawMapping = new SparseArray<>();
+        for (int i = 0; i < rawList.size(); i++) {
+            final ActivityManager.RecentTaskInfo taskInfo = rawList.get(i);
+            rawMapping.put(taskInfo.taskId, taskInfo);
+        }
+
+        // Pull out the pairs as we iterate back in the list
+        ArrayList<GroupedRecentTaskInfo> recentTasks = new ArrayList<>();
+        for (int i = 0; i < rawList.size(); i++) {
+            final ActivityManager.RecentTaskInfo taskInfo = rawList.get(i);
+            if (!rawMapping.contains(taskInfo.taskId)) {
+                // If it's not in the mapping, then it was already paired with another task
+                continue;
+            }
+
+            final int pairedTaskId = mSplitTasks.get(taskInfo.taskId);
+            if (pairedTaskId != INVALID_TASK_ID) {
+                final ActivityManager.RecentTaskInfo pairedTaskInfo = rawMapping.get(pairedTaskId);
+                rawMapping.remove(pairedTaskId);
+                recentTasks.add(new GroupedRecentTaskInfo(taskInfo, pairedTaskInfo));
+            } else {
+                recentTasks.add(new GroupedRecentTaskInfo(taskInfo));
+            }
+        }
+        return recentTasks;
+    }
+
+    public void dump(@NonNull PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        ArrayList<GroupedRecentTaskInfo> recentTasks = getRecentTasks(Integer.MAX_VALUE,
+                ActivityManager.RECENT_IGNORE_UNAVAILABLE, ActivityManager.getCurrentUser());
+        for (int i = 0; i < recentTasks.size(); i++) {
+            pw.println(innerPrefix + recentTasks.get(i));
+        }
+    }
+
+    /**
+     * The interface for calls from outside the Shell, within the host process.
+     */
+    @ExternalThread
+    private class RecentTasksImpl implements RecentTasks {
+        private IRecentTasksImpl mIRecentTasks;
+
+        @Override
+        public IRecentTasks createExternalInterface() {
+            if (mIRecentTasks != null) {
+                mIRecentTasks.invalidate();
+            }
+            mIRecentTasks = new IRecentTasksImpl(RecentTasksController.this);
+            return mIRecentTasks;
+        }
+    }
+
+
+    /**
+     * The interface for calls from outside the host process.
+     */
+    @BinderThread
+    private static class IRecentTasksImpl extends IRecentTasks.Stub {
+        private RecentTasksController mController;
+        private final SingleInstanceRemoteListener<RecentTasksController,
+                IRecentTasksListener> mListener;
+        private final Runnable mRecentTasksListener =
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        mListener.call(l -> l.onRecentTasksChanged());
+                    }
+                };
+
+        public IRecentTasksImpl(RecentTasksController controller) {
+            mController = controller;
+            mListener = new SingleInstanceRemoteListener<>(controller,
+                    c -> c.registerRecentTasksListener(mRecentTasksListener),
+                    c -> c.unregisterRecentTasksListener(mRecentTasksListener));
+        }
+
+        /**
+         * Invalidates this instance, preventing future calls from updating the controller.
+         */
+        void invalidate() {
+            mController = null;
+        }
+
+        @Override
+        public void registerRecentTasksListener(IRecentTasksListener listener)
+                throws RemoteException {
+            executeRemoteCallWithTaskPermission(mController, "registerRecentTasksListener",
+                    (controller) -> mListener.register(listener));
+        }
+
+        @Override
+        public void unregisterRecentTasksListener(IRecentTasksListener listener)
+                throws RemoteException {
+            executeRemoteCallWithTaskPermission(mController, "unregisterRecentTasksListener",
+                    (controller) -> mListener.unregister());
+        }
+
+        @Override
+        public GroupedRecentTaskInfo[] getRecentTasks(int maxNum, int flags, int userId)
+                throws RemoteException {
+            final GroupedRecentTaskInfo[][] out = new GroupedRecentTaskInfo[][]{null};
+            executeRemoteCallWithTaskPermission(mController, "getRecentTasks",
+                    (controller) -> out[0] = controller.getRecentTasks(maxNum, flags, userId)
+                            .toArray(new GroupedRecentTaskInfo[0]),
+                    true /* blocking */);
+            return out[0];
+        }
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.aidl b/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.aidl
new file mode 100644
index 0000000..15797cd
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2021 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.wm.shell.util;
+
+parcelable GroupedRecentTaskInfo;
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.java b/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.java
new file mode 100644
index 0000000..0331ba1
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/util/GroupedRecentTaskInfo.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2021 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.wm.shell.util;
+
+import android.app.ActivityManager;
+import android.app.WindowConfiguration;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Simple container for recent tasks.  May contain either a single or pair of tasks.
+ */
+public class GroupedRecentTaskInfo implements Parcelable {
+    public @NonNull ActivityManager.RecentTaskInfo mTaskInfo1;
+    public @Nullable ActivityManager.RecentTaskInfo mTaskInfo2;
+
+    public GroupedRecentTaskInfo(@NonNull ActivityManager.RecentTaskInfo task1) {
+        this(task1, null);
+    }
+
+    public GroupedRecentTaskInfo(@NonNull ActivityManager.RecentTaskInfo task1,
+            @Nullable ActivityManager.RecentTaskInfo task2) {
+        mTaskInfo1 = task1;
+        mTaskInfo2 = task2;
+    }
+
+    GroupedRecentTaskInfo(Parcel parcel) {
+        mTaskInfo1 = parcel.readTypedObject(ActivityManager.RecentTaskInfo.CREATOR);
+        mTaskInfo2 = parcel.readTypedObject(ActivityManager.RecentTaskInfo.CREATOR);
+    }
+
+    @Override
+    public String toString() {
+        return "Task1: " + getTaskInfo(mTaskInfo1) + ", Task2: " + getTaskInfo(mTaskInfo2);
+    }
+
+    private String getTaskInfo(ActivityManager.RecentTaskInfo taskInfo) {
+        if (taskInfo == null) {
+            return null;
+        }
+        return "id=" + taskInfo.taskId
+                + " baseIntent=" + (taskInfo.baseIntent != null
+                        ? taskInfo.baseIntent.getComponent()
+                        : "null")
+                + " winMode=" + WindowConfiguration.windowingModeToString(
+                        taskInfo.getWindowingMode());
+    }
+
+    @Override
+    public void writeToParcel(Parcel parcel, int flags) {
+        parcel.writeTypedObject(mTaskInfo1, flags);
+        parcel.writeTypedObject(mTaskInfo2, flags);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final @android.annotation.NonNull Creator<GroupedRecentTaskInfo> CREATOR =
+            new Creator<GroupedRecentTaskInfo>() {
+        public GroupedRecentTaskInfo createFromParcel(Parcel source) {
+            return new GroupedRecentTaskInfo(source);
+        }
+        public GroupedRecentTaskInfo[] newArray(int size) {
+            return new GroupedRecentTaskInfo[size];
+        }
+    };
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTaskOrganizerTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTaskOrganizerTests.java
index d5acbbcf..1fcbf14 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTaskOrganizerTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTaskOrganizerTests.java
@@ -65,6 +65,7 @@
 import org.mockito.MockitoAnnotations;
 
 import java.util.ArrayList;
+import java.util.Optional;
 
 /**
  * Tests for the shell task organizer.
@@ -131,7 +132,7 @@
                     .when(mTaskOrganizerController).registerTaskOrganizer(any());
         } catch (RemoteException e) {}
         mOrganizer = spy(new ShellTaskOrganizer(mTaskOrganizerController, mTestExecutor, mContext,
-                mSizeCompatUI));
+                mSizeCompatUI, Optional.empty()));
     }
 
     @Test
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
index 5bdf831..6080f3a 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/ShellTestCase.java
@@ -26,6 +26,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.mockito.MockitoAnnotations;
 
 /**
  * Base class that does shell test case setup.
@@ -36,6 +37,7 @@
 
     @Before
     public void shellSetup() {
+        MockitoAnnotations.initMocks(this);
         final Context context =
                 InstrumentationRegistry.getInstrumentation().getTargetContext();
         final DisplayManager dm = context.getSystemService(DisplayManager.class);
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
new file mode 100644
index 0000000..a1e1231
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (C) 2021 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.wm.shell.recents;
+
+import static android.app.ActivityManager.RECENT_IGNORE_UNAVAILABLE;
+import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
+import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import static java.lang.Integer.MAX_VALUE;
+
+import android.app.ActivityManager;
+import android.app.WindowConfiguration;
+import android.content.Context;
+import android.view.SurfaceControl;
+import android.window.TaskAppearedInfo;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.ShellTestCase;
+import com.android.wm.shell.TestShellExecutor;
+import com.android.wm.shell.common.ShellExecutor;
+import com.android.wm.shell.common.TaskStackListenerImpl;
+import com.android.wm.shell.util.GroupedRecentTaskInfo;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Optional;
+
+/**
+ * Tests for {@link RecentTasksController}.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RecentTasksControllerTest extends ShellTestCase {
+
+    @Mock
+    private Context mContext;
+    @Mock
+    private TaskStackListenerImpl mTaskStackListener;
+
+    private ShellTaskOrganizer mShellTaskOrganizer;
+    private RecentTasksController mRecentTasksController;
+    private ShellExecutor mMainExecutor;
+
+    @Before
+    public void setUp() {
+        mMainExecutor = new TestShellExecutor();
+        mRecentTasksController = spy(new RecentTasksController(mContext, mTaskStackListener,
+                mMainExecutor));
+        mShellTaskOrganizer = new ShellTaskOrganizer(mMainExecutor, mContext,
+                null /* sizeCompatUI */, Optional.of(mRecentTasksController));
+    }
+
+    @Test
+    public void testGetRecentTasks() {
+        ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
+        ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
+        ActivityManager.RecentTaskInfo t3 = makeTaskInfo(3);
+        setRawList(t1, t2, t3);
+
+        ArrayList<GroupedRecentTaskInfo> recentTasks = mRecentTasksController.getRecentTasks(
+                MAX_VALUE, RECENT_IGNORE_UNAVAILABLE, 0);
+        assertGroupedTasksListEquals(recentTasks,
+                t1.taskId, -1,
+                t2.taskId, -1,
+                t3.taskId, -1);
+    }
+
+    @Test
+    public void testGetRecentTasks_withPairs() {
+        ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
+        ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
+        ActivityManager.RecentTaskInfo t3 = makeTaskInfo(3);
+        ActivityManager.RecentTaskInfo t4 = makeTaskInfo(4);
+        ActivityManager.RecentTaskInfo t5 = makeTaskInfo(5);
+        ActivityManager.RecentTaskInfo t6 = makeTaskInfo(6);
+        setRawList(t1, t2, t3, t4, t5, t6);
+
+        // Mark a couple pairs [t2, t4], [t3, t5]
+        mRecentTasksController.addSplitPair(t2.taskId, t4.taskId);
+        mRecentTasksController.addSplitPair(t3.taskId, t5.taskId);
+
+        ArrayList<GroupedRecentTaskInfo> recentTasks = mRecentTasksController.getRecentTasks(
+                MAX_VALUE, RECENT_IGNORE_UNAVAILABLE, 0);
+        assertGroupedTasksListEquals(recentTasks,
+                t1.taskId, -1,
+                t2.taskId, t4.taskId,
+                t3.taskId, t5.taskId,
+                t6.taskId, -1);
+    }
+
+    @Test
+    public void testRemovedTaskRemovesSplit() {
+        ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
+        ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
+        ActivityManager.RecentTaskInfo t3 = makeTaskInfo(3);
+        setRawList(t1, t2, t3);
+
+        // Add a pair
+        mRecentTasksController.addSplitPair(t2.taskId, t3.taskId);
+        reset(mRecentTasksController);
+
+        // Remove one of the tasks and ensure the pair is removed
+        SurfaceControl mockLeash = mock(SurfaceControl.class);
+        ActivityManager.RunningTaskInfo rt2 = makeRunningTaskInfo(2);
+        mShellTaskOrganizer.onTaskAppeared(rt2, mockLeash);
+        mShellTaskOrganizer.onTaskVanished(rt2);
+
+        verify(mRecentTasksController).removeSplitPair(t2.taskId);
+    }
+
+    @Test
+    public void testTaskWindowingModeChangedNotifiesChange() {
+        ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
+        setRawList(t1);
+
+        // Remove one of the tasks and ensure the pair is removed
+        SurfaceControl mockLeash = mock(SurfaceControl.class);
+        ActivityManager.RunningTaskInfo rt2Fullscreen = makeRunningTaskInfo(2);
+        rt2Fullscreen.configuration.windowConfiguration.setWindowingMode(
+                WINDOWING_MODE_FULLSCREEN);
+        mShellTaskOrganizer.onTaskAppeared(rt2Fullscreen, mockLeash);
+
+        // Change the windowing mode and ensure the recent tasks change is notified
+        ActivityManager.RunningTaskInfo rt2MultiWIndow = makeRunningTaskInfo(2);
+        rt2MultiWIndow.configuration.windowConfiguration.setWindowingMode(
+                WINDOWING_MODE_MULTI_WINDOW);
+        mShellTaskOrganizer.onTaskInfoChanged(rt2MultiWIndow);
+
+        verify(mRecentTasksController).notifyRecentTasksChanged();
+    }
+
+    /**
+     * Helper to create a task with a given task id.
+     */
+    private ActivityManager.RecentTaskInfo makeTaskInfo(int taskId) {
+        ActivityManager.RecentTaskInfo info = new ActivityManager.RecentTaskInfo();
+        info.taskId = taskId;
+        return info;
+    }
+
+    /**
+     * Helper to create a running task with a given task id.
+     */
+    private ActivityManager.RunningTaskInfo makeRunningTaskInfo(int taskId) {
+        ActivityManager.RunningTaskInfo info = new ActivityManager.RunningTaskInfo();
+        info.taskId = taskId;
+        return info;
+    }
+
+    /**
+     * Helper to set the raw task list on the controller.
+     */
+    private ArrayList<ActivityManager.RecentTaskInfo> setRawList(
+            ActivityManager.RecentTaskInfo... tasks) {
+        ArrayList<ActivityManager.RecentTaskInfo> rawList = new ArrayList<>();
+        for (ActivityManager.RecentTaskInfo task : tasks) {
+            rawList.add(task);
+        }
+        doReturn(rawList).when(mRecentTasksController).getRawRecentTasks(anyInt(), anyInt(),
+                anyInt());
+        return rawList;
+    }
+
+    /**
+     * Asserts that the recent tasks matches the given task ids.
+     * @param expectedTaskIds list of task ids that map to the flattened task ids of the tasks in
+     *                        the grouped task list
+     */
+    private void assertGroupedTasksListEquals(ArrayList<GroupedRecentTaskInfo> recentTasks,
+            int... expectedTaskIds) {
+        int[] flattenedTaskIds = new int[recentTasks.size() * 2];
+        for (int i = 0; i < recentTasks.size(); i++) {
+            GroupedRecentTaskInfo pair = recentTasks.get(i);
+            flattenedTaskIds[2 * i] = pair.mTaskInfo1.taskId;
+            flattenedTaskIds[2 * i + 1] = pair.mTaskInfo2 != null
+                    ? pair.mTaskInfo2.taskId
+                    : -1;
+        }
+        assertTrue("Expected: " + Arrays.toString(expectedTaskIds)
+                        + " Received: " + Arrays.toString(flattenedTaskIds),
+                Arrays.equals(flattenedTaskIds, expectedTaskIds));
+    }
+}