Allow app pairs in folders

This CL substantially refactors folders to be able to take contents of type AppPairInfo. App pairs can now be moved in and out of folders, and launch from folders.

This CL contains only logic and model changes; animation and style changes (for dropping items into folders, color changes to app pair surfaces, etc.) will be in a following CL. Another CL (hopefully) will contain tests. I'm planning to submit them together, but this patch should also be able to stand alone with no issues (except janky transitions).

Bug: 315731527
Flag: ACONFIG com.android.wm.shell.enable_app_pairs TRUNKFOOD
Test: Manual, more to follow in another CL.
Change-Id: I73732fcaefbdc61bf6e02a5be365962b8bbc3e41
(cherry picked from commit a596f589c4de70050cc0343a037e06f6860e2bdc)
Merged-In: I73732fcaefbdc61bf6e02a5be365962b8bbc3e41
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
index 4462f20..2730be1 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarPopupController.java
@@ -118,7 +118,7 @@
                 FolderInfo fi = (FolderInfo) info;
                 if (fi.anyMatch(matcher)) {
                     FolderDotInfo folderDotInfo = new FolderDotInfo();
-                    for (WorkspaceItemInfo si : fi.getContents()) {
+                    for (ItemInfo si : fi.getContents()) {
                         folderDotInfo.addDotInfo(mPopupDataProvider.getDotInfoForItem(si));
                     }
                     ((FolderIcon) v).setDotInfo(folderDotInfo);
diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java
index f4da867..59bf105 100644
--- a/quickstep/src/com/android/quickstep/util/AppPairsController.java
+++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java
@@ -153,7 +153,7 @@
 
         IconCache iconCache = LauncherAppState.getInstance(mContext).getIconCache();
         MODEL_EXECUTOR.execute(() -> {
-            newAppPair.getContents().forEach(member -> {
+            newAppPair.getAppContents().forEach(member -> {
                 member.title = "";
                 member.bitmap = iconCache.getDefaultIcon(newAppPair.user);
                 iconCache.getTitleAndIcon(member, member.usingLowResIcon());
diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
index 6b27004..a2d3859 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt
@@ -671,6 +671,7 @@
                 appIcon2,
                 dividerPos
             )
+        floatingView.bringToFront()
 
         // Launcher animation: animate the floating view, expanding to fill the display surface
         progressUpdater.addUpdateListener(
diff --git a/res/layout/folder_app_pair.xml b/res/layout/folder_app_pair.xml
new file mode 100644
index 0000000..acecd46
--- /dev/null
+++ b/res/layout/folder_app_pair.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Copyright (C) 2024 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.launcher3.apppairs.AppPairIcon
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:launcher="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:focusable="true"
+    launcher:iconDisplay="folder" >
+    <com.android.launcher3.apppairs.AppPairIconGraphic
+        android:id="@+id/app_pair_icon_graphic"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:focusable="false" />
+    <com.android.launcher3.BubbleTextView
+        style="@style/BaseIcon"
+        android:id="@+id/app_pair_icon_name"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:focusable="false"
+        android:layout_gravity="top"
+        android:textColor="?attr/folderTextColor"
+        launcher:iconDisplay="folder" />
+</com.android.launcher3.apppairs.AppPairIcon>
\ No newline at end of file
diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java
index ce3c55a..e80156c 100644
--- a/src/com/android/launcher3/Workspace.java
+++ b/src/com/android/launcher3/Workspace.java
@@ -17,7 +17,7 @@
 package com.android.launcher3;
 
 import static com.android.launcher3.LauncherAnimUtils.SPRING_LOADED_EXIT_DELAY;
-import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
+import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
 import static com.android.launcher3.LauncherState.ALL_APPS;
 import static com.android.launcher3.LauncherState.EDIT_MODE;
 import static com.android.launcher3.LauncherState.FLAG_MULTI_PAGE;
@@ -1873,12 +1873,9 @@
             return false;
         }
 
-        boolean aboveShortcut = (dropOverView.getTag() instanceof WorkspaceItemInfo
-                && ((WorkspaceItemInfo) dropOverView.getTag()).container
-                != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION);
-        boolean willBecomeShortcut =
-                (info.itemType == ITEM_TYPE_APPLICATION ||
-                        info.itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT);
+        boolean aboveShortcut = Folder.willAccept(dropOverView.getTag())
+                && ((ItemInfo) dropOverView.getTag()).container != CONTAINER_HOTSEAT_PREDICTION;
+        boolean willBecomeShortcut = Folder.willAcceptItemType(info.itemType);
 
         return (aboveShortcut && willBecomeShortcut);
     }
@@ -1925,12 +1922,12 @@
         mCreateUserFolderOnDrop = false;
         final int screenId = getCellLayoutId(target);
 
-        boolean aboveShortcut = (v.getTag() instanceof WorkspaceItemInfo);
-        boolean willBecomeShortcut = (newView.getTag() instanceof WorkspaceItemInfo);
+        boolean aboveShortcut = Folder.willAccept(v.getTag());
+        boolean willBecomeShortcut = Folder.willAccept(newView.getTag());
 
         if (aboveShortcut && willBecomeShortcut) {
-            WorkspaceItemInfo sourceInfo = (WorkspaceItemInfo) newView.getTag();
-            WorkspaceItemInfo destInfo = (WorkspaceItemInfo) v.getTag();
+            ItemInfo sourceInfo = (ItemInfo) newView.getTag();
+            ItemInfo destInfo = (ItemInfo) v.getTag();
             // if the drag started here, we need to remove it from the workspace
             if (!external) {
                 getParentCellLayoutForView(mDragInfo.cell).removeView(mDragInfo.cell);
@@ -3314,7 +3311,7 @@
                     }
                 } else if (child instanceof FolderIcon) {
                     FolderInfo folderInfo = (FolderInfo) info;
-                    List<WorkspaceItemInfo> matches = folderInfo.getContents().stream()
+                    List<ItemInfo> matches = folderInfo.getContents().stream()
                             .filter(matcher)
                             .collect(Collectors.toList());
                     if (!matches.isEmpty()) {
@@ -3381,7 +3378,7 @@
                 FolderInfo fi = (FolderInfo) info;
                 if (fi.anyMatch(matcher)) {
                     FolderDotInfo folderDotInfo = new FolderDotInfo();
-                    for (WorkspaceItemInfo si : fi.getContents()) {
+                    for (ItemInfo si : fi.getContents()) {
                         folderDotInfo.addDotInfo(mLauncher.getDotInfoForItem(si));
                     }
                     ((FolderIcon) v).setDotInfo(folderDotInfo);
diff --git a/src/com/android/launcher3/accessibility/WorkspaceAccessibilityHelper.java b/src/com/android/launcher3/accessibility/WorkspaceAccessibilityHelper.java
index 52073cc..84d6a6f 100644
--- a/src/com/android/launcher3/accessibility/WorkspaceAccessibilityHelper.java
+++ b/src/com/android/launcher3/accessibility/WorkspaceAccessibilityHelper.java
@@ -23,6 +23,7 @@
 import com.android.launcher3.CellLayout;
 import com.android.launcher3.R;
 import com.android.launcher3.accessibility.BaseAccessibilityDelegate.DragType;
+import com.android.launcher3.folder.Folder;
 import com.android.launcher3.model.data.AppInfo;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.ItemInfo;
@@ -117,7 +118,7 @@
             return mContext.getString(R.string.item_moved);
         } else {
             ItemInfo info = (ItemInfo) child.getTag();
-            if (info instanceof AppInfo || info instanceof WorkspaceItemInfo) {
+            if (Folder.willAccept(info)) {
                 return mContext.getString(R.string.folder_created);
 
             } else if (info instanceof FolderInfo) {
@@ -148,8 +149,8 @@
             if (TextUtils.isEmpty(info.title)) {
                 // Find the first item in the folder.
                 FolderInfo folder = (FolderInfo) info;
-                WorkspaceItemInfo firstItem = null;
-                for (WorkspaceItemInfo shortcut : folder.getContents()) {
+                ItemInfo firstItem = null;
+                for (ItemInfo shortcut : folder.getContents()) {
                     if (firstItem == null || firstItem.rank > shortcut.rank) {
                         firstItem = shortcut;
                     }
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
index 9010f82..8ce1c19 100644
--- a/src/com/android/launcher3/apppairs/AppPairIcon.java
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -30,11 +30,13 @@
 
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.LauncherAppState;
 import com.android.launcher3.R;
 import com.android.launcher3.Reorderable;
 import com.android.launcher3.dragndrop.DraggableView;
+import com.android.launcher3.icons.IconCache;
 import com.android.launcher3.model.data.AppPairInfo;
-import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.util.MultiTranslateDelegate;
 import com.android.launcher3.views.ActivityContext;
 
@@ -180,9 +182,17 @@
     }
 
     /**
+     * Ensures that both app icons in the pair are loaded in high resolution.
+     */
+    public void verifyHighRes() {
+        IconCache iconCache = LauncherAppState.getInstance(getContext()).getIconCache();
+        getInfo().fetchHiResIconsIfNeeded(iconCache);
+    }
+
+    /**
      * Called when WorkspaceItemInfos get updated, and the app pair icon may need to be redrawn.
      */
-    public void maybeRedrawForWorkspaceUpdate(Predicate<WorkspaceItemInfo> itemCheck) {
+    public void maybeRedrawForWorkspaceUpdate(Predicate<ItemInfo> itemCheck) {
         // If either of the app pair icons return true on the predicate (i.e. in the list of
         // updated apps), redraw the icon graphic (icon background and both icons).
         if (getInfo().anyMatch(itemCheck)) {
diff --git a/src/com/android/launcher3/apppairs/AppPairIconDrawable.java b/src/com/android/launcher3/apppairs/AppPairIconDrawable.java
index c0ac11a..db83d91 100644
--- a/src/com/android/launcher3/apppairs/AppPairIconDrawable.java
+++ b/src/com/android/launcher3/apppairs/AppPairIconDrawable.java
@@ -32,7 +32,7 @@
  * A composed Drawable consisting of the two app pair icons and the background behind them (looks
  * like two rectangles).
  */
-class AppPairIconDrawable extends Drawable {
+public class AppPairIconDrawable extends Drawable {
     private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
     private final AppPairIconDrawingParams mP;
     private final FastBitmapDrawable mIcon1;
@@ -102,6 +102,7 @@
         }
 
         mIcon2.draw(canvas);
+        canvas.restore();
     }
 
     /**
@@ -205,4 +206,14 @@
     public void setColorFilter(ColorFilter colorFilter) {
         mBackgroundPaint.setColorFilter(colorFilter);
     }
+
+    @Override
+    public int getIntrinsicWidth() {
+        return mP.getIconSize();
+    }
+
+    @Override
+    public int getIntrinsicHeight() {
+        return mP.getIconSize();
+    }
 }
diff --git a/src/com/android/launcher3/apppairs/AppPairIconDrawingParams.kt b/src/com/android/launcher3/apppairs/AppPairIconDrawingParams.kt
index 62e5771..e2c2670 100644
--- a/src/com/android/launcher3/apppairs/AppPairIconDrawingParams.kt
+++ b/src/com/android/launcher3/apppairs/AppPairIconDrawingParams.kt
@@ -77,22 +77,29 @@
         innerPadding = iconSize * INNER_PADDING_SCALE
         memberIconSize = iconSize * MEMBER_ICON_SCALE
         updateOrientation(dp)
-        if (container == DISPLAY_FOLDER) {
-            val ta =
-                context.theme.obtainStyledAttributes(
-                    intArrayOf(R.attr.materialColorSurfaceContainerLowest)
-                )
-            bgColor = ta.getColor(0, 0)
-            ta.recycle()
-        } else {
-            val ta = context.theme.obtainStyledAttributes(R.styleable.FolderIconPreview)
-            bgColor = ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0)
-            ta.recycle()
-        }
+        bgColor = getBgColorForContainer(container)
     }
 
     /** Checks the device orientation and updates isLeftRightSplit accordingly. */
     fun updateOrientation(dp: DeviceProfile) {
         isLeftRightSplit = dp.isLeftRightSplit
     }
+
+    private fun getBgColorForContainer(container: Int): Int {
+        val color: Int
+        if (container == DISPLAY_FOLDER) {
+            val ta =
+                context.theme.obtainStyledAttributes(
+                    intArrayOf(R.attr.materialColorSurfaceContainerLowest)
+                )
+            color = ta.getColor(0, 0)
+            ta.recycle()
+        } else {
+            val ta = context.theme.obtainStyledAttributes(R.styleable.FolderIconPreview)
+            color = ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0)
+            ta.recycle()
+        }
+
+        return color
+    }
 }
diff --git a/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt
index a3a1cfc..86ee0c0 100644
--- a/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt
+++ b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt
@@ -19,7 +19,6 @@
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.Rect
-import android.graphics.drawable.Drawable
 import android.util.AttributeSet
 import android.view.Gravity
 import android.widget.FrameLayout
@@ -52,7 +51,10 @@
          * 2) One of the member apps can't be launched due to screen size requirements.
          */
         @JvmStatic
-        fun composeDrawable(appPairInfo: AppPairInfo, p: AppPairIconDrawingParams): Drawable {
+        fun composeDrawable(
+            appPairInfo: AppPairInfo,
+            p: AppPairIconDrawingParams
+        ): AppPairIconDrawable {
             // Generate new icons, using themed flag if needed.
             val flags = if (Themes.isThemedIconEnabled(p.context)) BitmapInfo.FLAG_THEMED else 0
             val appIcon1 = appPairInfo.getFirstApp().newIcon(p.context, flags)
@@ -81,7 +83,7 @@
 
     private lateinit var parentIcon: AppPairIcon
     private lateinit var drawParams: AppPairIconDrawingParams
-    private lateinit var drawable: Drawable
+    lateinit var drawable: AppPairIconDrawable
 
     fun init(icon: AppPairIcon, container: Int) {
         parentIcon = icon
@@ -116,12 +118,6 @@
         redraw()
     }
 
-    /** Updates the icon drawable and redraws it */
-    fun redraw() {
-        drawable = composeDrawable(parentIcon.info, drawParams)
-        invalidate()
-    }
-
     /**
      * Gets this icon graphic's visual bounds, with respect to the parent icon's coordinate system.
      */
@@ -136,6 +132,12 @@
         )
     }
 
+    /** Updates the icon drawable and redraws it */
+    fun redraw() {
+        drawable = composeDrawable(parentIcon.info, drawParams)
+        invalidate()
+    }
+
     override fun dispatchDraw(canvas: Canvas) {
         super.dispatchDraw(canvas)
         drawable.draw(canvas)
diff --git a/src/com/android/launcher3/folder/Folder.java b/src/com/android/launcher3/folder/Folder.java
index 474108e..5c74108 100644
--- a/src/com/android/launcher3/folder/Folder.java
+++ b/src/com/android/launcher3/folder/Folder.java
@@ -19,6 +19,9 @@
 import static android.text.TextUtils.isEmpty;
 
 import static com.android.launcher3.LauncherAnimUtils.SPRING_LOADED_EXIT_DELAY;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPLICATION;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
+import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT;
 import static com.android.launcher3.LauncherState.EDIT_MODE;
 import static com.android.launcher3.LauncherState.NORMAL;
 import static com.android.launcher3.compat.AccessibilityManagerCompat.sendCustomAccessibilityEvent;
@@ -66,14 +69,12 @@
 
 import com.android.launcher3.AbstractFloatingView;
 import com.android.launcher3.Alarm;
-import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.CellLayout;
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.DragSource;
 import com.android.launcher3.DropTarget;
 import com.android.launcher3.ExtendedEditText;
 import com.android.launcher3.Launcher;
-import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.OnAlarmListener;
 import com.android.launcher3.R;
 import com.android.launcher3.ShortcutAndWidgetContainer;
@@ -166,6 +167,22 @@
     private static final Rect sTempRect = new Rect();
     private static final int MIN_FOLDERS_FOR_HARDWARE_OPTIMIZATION = 10;
 
+    /**
+     * Checks if {@code o} is an {@link ItemInfo} type that can be placed in folders.
+     */
+    public static boolean willAccept(Object o) {
+        return o instanceof ItemInfo info && willAcceptItemType(info.itemType);
+    }
+
+    /**
+     * Checks if {@code itemType} is a type that can be placed in folders.
+     */
+    public static boolean willAcceptItemType(int itemType) {
+        return itemType == ITEM_TYPE_APPLICATION
+                || itemType == ITEM_TYPE_DEEP_SHORTCUT
+                || itemType == ITEM_TYPE_APP_PAIR;
+    }
+
     private final Alarm mReorderAlarm = new Alarm(Looper.getMainLooper());
     private final Alarm mOnExitAlarm = new Alarm(Looper.getMainLooper());
     private final Alarm mOnScrollHintAlarm = new Alarm(Looper.getMainLooper());
@@ -310,9 +327,7 @@
 
     public boolean startDrag(View v, DragOptions options) {
         Object tag = v.getTag();
-        if (tag instanceof WorkspaceItemInfo) {
-            WorkspaceItemInfo item = (WorkspaceItemInfo) tag;
-
+        if (tag instanceof ItemInfo item) {
             mEmptyCellRank = item.rank;
             mCurrentDragView = v;
 
@@ -343,14 +358,12 @@
         }
 
         mContent.removeItem(mCurrentDragView);
-        if (dragObject.dragInfo instanceof WorkspaceItemInfo) {
-            mItemsInvalidated = true;
+        mItemsInvalidated = true;
 
-            // We do not want to get events for the item being removed, as they will get handled
-            // when the drop completes
-            try (SuppressInfoChanges s = new SuppressInfoChanges()) {
-                mInfo.remove((WorkspaceItemInfo) dragObject.dragInfo, true);
-            }
+        // We do not want to get events for the item being removed, as they will get handled
+        // when the drop completes
+        try (SuppressInfoChanges s = new SuppressInfoChanges()) {
+            mInfo.remove(dragObject.dragInfo, true);
         }
         mDragInProgress = true;
         mItemAddedBackToSelfViaIcon = false;
@@ -490,7 +503,7 @@
         mInfo = info;
         mFromTitle = info.title;
         mFromLabelState = info.getFromLabelState();
-        ArrayList<WorkspaceItemInfo> children = info.getContents();
+        ArrayList<ItemInfo> children = info.getContents();
         Collections.sort(children, ITEM_POS_COMPARATOR);
         updateItemLocationsInDatabaseBatch(true);
 
@@ -623,7 +636,7 @@
         // onDropComplete. Perform cleanup once drag-n-drop ends.
         mDragController.addDragListener(this);
 
-        ArrayList<WorkspaceItemInfo> items = new ArrayList<>(mInfo.getContents());
+        ArrayList<ItemInfo> items = new ArrayList<>(mInfo.getContents());
         mEmptyCellRank = items.size();
         items.add(null);    // Add an empty spot at the end
 
@@ -644,7 +657,7 @@
      * is animated relative to the specified View. If the View is null, no animation
      * is played.
      */
-    private void animateOpen(List<WorkspaceItemInfo> items, int pageNo) {
+    private void animateOpen(List<ItemInfo> items, int pageNo) {
         if (items == null || items.size() <= 1) {
             Log.d(TAG, "Couldn't animate folder open because items is: " + items);
             return;
@@ -893,8 +906,7 @@
     public boolean acceptDrop(DragObject d) {
         final ItemInfo item = d.dragInfo;
         final int itemType = item.itemType;
-        return ((itemType == LauncherSettings.Favorites.ITEM_TYPE_APPLICATION ||
-                itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT));
+        return Folder.willAcceptItemType(itemType);
     }
 
     public void onDragEnter(DragObject d) {
@@ -1047,7 +1059,7 @@
             }
         } else {
             // The drag failed, we need to return the item to the folder
-            WorkspaceItemInfo info = (WorkspaceItemInfo) d.dragInfo;
+            ItemInfo info = d.dragInfo;
             View icon = (mCurrentDragView != null && mCurrentDragView.getTag() == info)
                     ? mCurrentDragView : mContent.createNewView(info);
             ArrayList<View> views = getIconsInReadingOrder();
@@ -1096,7 +1108,7 @@
         ArrayList<ItemInfo> items = new ArrayList<>();
         int total = mInfo.getContents().size();
         for (int i = 0; i < total; i++) {
-            WorkspaceItemInfo itemInfo = mInfo.getContents().get(i);
+            ItemInfo itemInfo = mInfo.getContents().get(i);
             if (verifier.updateRankAndPos(itemInfo, i)) {
                 items.add(itemInfo);
             }
@@ -1109,8 +1121,7 @@
             Executors.MODEL_EXECUTOR.post(() -> {
                 FolderNameInfos nameInfos = new FolderNameInfos();
                 FolderNameProvider fnp = FolderNameProvider.newInstance(getContext());
-                fnp.getSuggestedFolderName(
-                        getContext(), mInfo.getContents(), nameInfos);
+                fnp.getSuggestedFolderName(getContext(), mInfo.getAppContents(), nameInfos);
                 mInfo.suggestedFolderNames = nameInfos;
             });
         }
@@ -1295,15 +1306,15 @@
             d.deferDragViewCleanupPostAnimation = false;
             mRearrangeOnClose = true;
         } else {
-            final WorkspaceItemInfo si;
+            final ItemInfo si;
             if (pasiSi != null) {
                 si = pasiSi;
             } else if (d.dragInfo instanceof WorkspaceItemFactory) {
                 // Came from all apps -- make a copy.
                 si = ((WorkspaceItemFactory) d.dragInfo).makeWorkspaceItem(launcher);
             } else {
-                // WorkspaceItemInfo
-                si = (WorkspaceItemInfo) d.dragInfo;
+                // WorkspaceItemInfo or AppPairInfo
+                si = d.dragInfo;
             }
 
             View currentDragView;
@@ -1311,7 +1322,7 @@
                 currentDragView = mContent.createAndAddViewForRank(si, mEmptyCellRank);
 
                 // Actually move the item in the database if it was an external drag. Call this
-                // before creating the view, so that WorkspaceItemInfo is updated appropriately.
+                // before creating the view, so that the ItemInfo is updated appropriately.
                 mLauncherDelegate.getModelWriter().addOrMoveItemInDatabase(
                         si, mInfo.id, 0, si.cellX, si.cellY);
                 mIsExternalDrag = false;
@@ -1373,14 +1384,14 @@
     // This is used so the item doesn't immediately appear in the folder when added. In one case
     // we need to create the illusion that the item isn't added back to the folder yet, to
     // to correspond to the animation of the icon back into the folder. This is
-    public void hideItem(WorkspaceItemInfo info) {
+    public void hideItem(ItemInfo info) {
         View v = getViewForInfo(info);
         if (v != null) {
             v.setVisibility(INVISIBLE);
         }
     }
 
-    public void showItem(WorkspaceItemInfo info) {
+    public void showItem(ItemInfo info) {
         View v = getViewForInfo(info);
         if (v != null) {
             v.setVisibility(VISIBLE);
@@ -1388,7 +1399,7 @@
     }
 
     @Override
-    public void onAdd(WorkspaceItemInfo item, int rank) {
+    public void onAdd(ItemInfo item, int rank) {
         FolderGridOrganizer verifier = new FolderGridOrganizer(
                 mActivityContext.getDeviceProfile()).setFolderInfo(mInfo);
         verifier.updateRankAndPos(item, rank);
@@ -1403,7 +1414,7 @@
     }
 
     @Override
-    public void onRemove(List<WorkspaceItemInfo> items) {
+    public void onRemove(List<ItemInfo> items) {
         mItemsInvalidated = true;
         items.stream().map(this::getViewForInfo).forEach(mContent::removeItem);
         if (mState == STATE_ANIMATING) {
@@ -1420,7 +1431,7 @@
         }
     }
 
-    private View getViewForInfo(final WorkspaceItemInfo item) {
+    private View getViewForInfo(final ItemInfo item) {
         return mContent.iterateOverItems((info, view) -> info == item);
     }
 
@@ -1448,7 +1459,7 @@
         return mItemsInReadingOrder;
     }
 
-    public List<BubbleTextView> getItemsOnPage(int page) {
+    public List<View> getItemsOnPage(int page) {
         ArrayList<View> allItems = getIconsInReadingOrder();
         int lastPage = mContent.getPageCount() - 1;
         int totalItemsInFolder = allItems.size();
@@ -1460,9 +1471,9 @@
         int startIndex = page * itemsPerPage;
         int endIndex = Math.min(startIndex + numItemsOnCurrentPage, allItems.size());
 
-        List<BubbleTextView> itemsOnCurrentPage = new ArrayList<>(numItemsOnCurrentPage);
+        List<View> itemsOnCurrentPage = new ArrayList<>(numItemsOnCurrentPage);
         for (int i = startIndex; i < endIndex; ++i) {
-            itemsOnCurrentPage.add((BubbleTextView) allItems.get(i));
+            itemsOnCurrentPage.add(allItems.get(i));
         }
         return itemsOnCurrentPage;
     }
diff --git a/src/com/android/launcher3/folder/FolderAnimationManager.java b/src/com/android/launcher3/folder/FolderAnimationManager.java
index a91373b..7a2ec97 100644
--- a/src/com/android/launcher3/folder/FolderAnimationManager.java
+++ b/src/com/android/launcher3/folder/FolderAnimationManager.java
@@ -43,6 +43,7 @@
 import com.android.launcher3.ShortcutAndWidgetContainer;
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.PropertyResetListener;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.util.Themes;
 import com.android.launcher3.views.BaseDragLayer;
@@ -127,7 +128,7 @@
                 (BaseDragLayer.LayoutParams) mFolder.getLayoutParams();
         mFolderIcon.getPreviewItemManager().recomputePreviewDrawingParams();
         ClippedFolderIconLayoutRule rule = mFolderIcon.getLayoutRule();
-        final List<BubbleTextView> itemsInPreview = getPreviewIconsOnPage(0);
+        final List<View> itemsInPreview = getPreviewIconsOnPage(0);
 
         // Match position of the FolderIcon
         final Rect folderIconPos = new Rect();
@@ -139,8 +140,8 @@
         // Match size/scale of icons in the preview
         float previewScale = rule.scaleForItem(itemsInPreview.size());
         float previewSize = rule.getIconSize() * previewScale;
-        float initialScale = previewSize / itemsInPreview.get(0).getIconSize()
-                * scaleRelativeToDragLayer;
+        float baseIconSize = getBubbleTextView(itemsInPreview.get(0)).getIconSize();
+        float initialScale = previewSize / baseIconSize * scaleRelativeToDragLayer;
         final float finalScale = 1f;
         float scale = mIsOpening ? initialScale : finalScale;
         mFolder.setPivotX(0);
@@ -198,11 +199,12 @@
         // Initialize the Folder items' text.
         PropertyResetListener colorResetListener =
                 new PropertyResetListener<>(TEXT_ALPHA_PROPERTY, 1f);
-        for (BubbleTextView icon : mFolder.getItemsOnPage(mFolder.mContent.getCurrentPage())) {
+        for (View icon : mFolder.getItemsOnPage(mFolder.mContent.getCurrentPage())) {
+            BubbleTextView titleText = getBubbleTextView(icon);
             if (mIsOpening) {
-                icon.setTextVisibility(false);
+                titleText.setTextVisibility(false);
             }
-            ObjectAnimator anim = icon.createTextAlphaAnimator(mIsOpening);
+            ObjectAnimator anim = titleText.createTextAlphaAnimator(mIsOpening);
             anim.addListener(colorResetListener);
             play(a, anim);
         }
@@ -339,7 +341,7 @@
     /**
      * Returns the list of "preview items" on {@param page}.
      */
-    private List<BubbleTextView> getPreviewIconsOnPage(int page) {
+    private List<View> getPreviewIconsOnPage(int page) {
         return mPreviewVerifier.setFolderInfo(mFolder.mInfo)
                 .previewItemsForPage(page, mFolder.getIconsInReadingOrder());
     }
@@ -351,7 +353,7 @@
             int previewItemOffsetX, int previewItemOffsetY) {
         ClippedFolderIconLayoutRule rule = mFolderIcon.getLayoutRule();
         boolean isOnFirstPage = mFolder.mContent.getCurrentPage() == 0;
-        final List<BubbleTextView> itemsInPreview = getPreviewIconsOnPage(
+        final List<View> itemsInPreview = getPreviewIconsOnPage(
                 isOnFirstPage ? 0 : mFolder.mContent.getCurrentPage());
         final int numItemsInPreview = itemsInPreview.size();
         final int numItemsInFirstPagePreview = isOnFirstPage
@@ -361,48 +363,49 @@
 
         ShortcutAndWidgetContainer cwc = mContent.getPageAt(0).getShortcutsAndWidgets();
         for (int i = 0; i < numItemsInPreview; ++i) {
-            final BubbleTextView btv = itemsInPreview.get(i);
-            CellLayoutLayoutParams btvLp = (CellLayoutLayoutParams) btv.getLayoutParams();
+            final View v = itemsInPreview.get(i);
+            CellLayoutLayoutParams vLp = (CellLayoutLayoutParams) v.getLayoutParams();
 
             // Calculate the final values in the LayoutParams.
-            btvLp.isLockedToGrid = true;
-            cwc.setupLp(btv);
+            vLp.isLockedToGrid = true;
+            cwc.setupLp(v);
 
             // Match scale of icons in the preview of the items on the first page.
             float previewScale = rule.scaleForItem(numItemsInFirstPagePreview);
             float previewSize = rule.getIconSize() * previewScale;
-            float iconScale = previewSize / itemsInPreview.get(i).getIconSize();
+            float baseIconSize = getBubbleTextView(v).getIconSize();
+            float iconScale = previewSize / baseIconSize;
 
             final float initialScale = iconScale / folderScale;
             final float finalScale = 1f;
             float scale = mIsOpening ? initialScale : finalScale;
-            btv.setScaleX(scale);
-            btv.setScaleY(scale);
+            v.setScaleX(scale);
+            v.setScaleY(scale);
 
             // Match positions of the icons in the folder with their positions in the preview
             rule.computePreviewItemDrawingParams(i, numItemsInFirstPagePreview, mTmpParams);
             // The PreviewLayoutRule assumes that the icon size takes up the entire width so we
             // offset by the actual size.
-            int iconOffsetX = (int) ((btvLp.width - btv.getIconSize()) * iconScale) / 2;
+            int iconOffsetX = (int) ((vLp.width - baseIconSize) * iconScale) / 2;
 
             final int previewPosX =
                     (int) ((mTmpParams.transX - iconOffsetX + previewItemOffsetX) / folderScale);
-            final float paddingTop = btv.getPaddingTop() * iconScale;
+            final float paddingTop = v.getPaddingTop() * iconScale;
             final int previewPosY = (int) ((mTmpParams.transY + previewItemOffsetY - paddingTop)
                     / folderScale);
 
-            final float xDistance = previewPosX - btvLp.x;
-            final float yDistance = previewPosY - btvLp.y;
+            final float xDistance = previewPosX - vLp.x;
+            final float yDistance = previewPosY - vLp.y;
 
-            Animator translationX = getAnimator(btv, View.TRANSLATION_X, xDistance, 0f);
+            Animator translationX = getAnimator(v, View.TRANSLATION_X, xDistance, 0f);
             translationX.setInterpolator(previewItemInterpolator);
             play(animatorSet, translationX);
 
-            Animator translationY = getAnimator(btv, View.TRANSLATION_Y, yDistance, 0f);
+            Animator translationY = getAnimator(v, View.TRANSLATION_Y, yDistance, 0f);
             translationY.setInterpolator(previewItemInterpolator);
             play(animatorSet, translationY);
 
-            Animator scaleAnimator = getAnimator(btv, SCALE_PROPERTY, initialScale, finalScale);
+            Animator scaleAnimator = getAnimator(v, SCALE_PROPERTY, initialScale, finalScale);
             scaleAnimator.setInterpolator(previewItemInterpolator);
             play(animatorSet, scaleAnimator);
 
@@ -426,20 +429,20 @@
                     super.onAnimationStart(animation);
                     // Necessary to initialize values here because of the start delay.
                     if (mIsOpening) {
-                        btv.setTranslationX(xDistance);
-                        btv.setTranslationY(yDistance);
-                        btv.setScaleX(initialScale);
-                        btv.setScaleY(initialScale);
+                        v.setTranslationX(xDistance);
+                        v.setTranslationY(yDistance);
+                        v.setScaleX(initialScale);
+                        v.setScaleY(initialScale);
                     }
                 }
 
                 @Override
                 public void onAnimationEnd(Animator animation) {
                     super.onAnimationEnd(animation);
-                    btv.setTranslationX(0.0f);
-                    btv.setTranslationY(0.0f);
-                    btv.setScaleX(1f);
-                    btv.setScaleY(1f);
+                    v.setTranslationX(0.0f);
+                    v.setTranslationY(0.0f);
+                    v.setScaleX(1f);
+                    v.setScaleY(1f);
                 }
             });
         }
@@ -482,4 +485,15 @@
                 ? ObjectAnimator.ofArgb(drawable, property, v1, v2)
                 : ObjectAnimator.ofArgb(drawable, property, v2, v1);
     }
+
+    /**
+     * Gets the {@link com.android.launcher3.BubbleTextView} from an icon. In some cases the
+     * BubbleTextView is the whole icon itself, while in others it is contained within the view and
+     * only serves to store the title text.
+     */
+    private BubbleTextView getBubbleTextView(View v) {
+        return v instanceof AppPairIcon
+                ? ((AppPairIcon) v).getTitleTextView()
+                : (BubbleTextView) v;
+    }
 }
diff --git a/src/com/android/launcher3/folder/FolderIcon.java b/src/com/android/launcher3/folder/FolderIcon.java
index 62ce311..6b30b95 100644
--- a/src/com/android/launcher3/folder/FolderIcon.java
+++ b/src/com/android/launcher3/folder/FolderIcon.java
@@ -71,6 +71,7 @@
 import com.android.launcher3.logger.LauncherAtom.ToState;
 import com.android.launcher3.logging.InstanceId;
 import com.android.launcher3.logging.StatsLogManager;
+import com.android.launcher3.model.data.AppPairInfo;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.FolderInfo.FolderListener;
 import com.android.launcher3.model.data.FolderInfo.LabelState;
@@ -118,7 +119,7 @@
     ClippedFolderIconLayoutRule mPreviewLayoutRule;
     private PreviewItemManager mPreviewItemManager;
     private PreviewItemDrawingParams mTmpParams = new PreviewItemDrawingParams(0, 0, 0);
-    private List<WorkspaceItemInfo> mCurrentPreviewItems = new ArrayList<>();
+    private List<ItemInfo> mCurrentPreviewItems = new ArrayList<>();
 
     boolean mAnimating = false;
 
@@ -215,7 +216,7 @@
 
         // Keep the notification dot up to date with the sum of all the content's dots.
         FolderDotInfo folderDotInfo = new FolderDotInfo();
-        for (WorkspaceItemInfo si : folderInfo.getContents()) {
+        for (ItemInfo si : folderInfo.getContents()) {
             folderDotInfo.addDotInfo(activity.getDotInfoForItem(si));
         }
         icon.setDotInfo(folderDotInfo);
@@ -261,20 +262,18 @@
 
     private boolean willAcceptItem(ItemInfo item) {
         final int itemType = item.itemType;
-        return ((itemType == LauncherSettings.Favorites.ITEM_TYPE_APPLICATION ||
-                itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT) &&
-                item != mInfo && !mFolder.isOpen());
+        return (Folder.willAcceptItemType(itemType) && item != mInfo && !mFolder.isOpen());
     }
 
     public boolean acceptDrop(ItemInfo dragInfo) {
         return !mFolder.isDestroyed() && willAcceptItem(dragInfo);
     }
 
-    public void addItem(WorkspaceItemInfo item) {
+    public void addItem(ItemInfo item) {
         mInfo.add(item, true);
     }
 
-    public void removeItem(WorkspaceItemInfo item, boolean animate) {
+    public void removeItem(ItemInfo item, boolean animate) {
         mInfo.remove(item, animate);
     }
 
@@ -287,8 +286,8 @@
         mOpenAlarm.setOnAlarmListener(mOnOpenListener);
         if (SPRING_LOADING_ENABLED &&
                 ((dragInfo instanceof WorkspaceItemFactory)
-                        || (dragInfo instanceof WorkspaceItemInfo)
-                        || (dragInfo instanceof PendingAddShortcutInfo))) {
+                        || (dragInfo instanceof PendingAddShortcutInfo)
+                        || Folder.willAccept(dragInfo))) {
             mOpenAlarm.setAlarm(ON_OPEN_DELAY);
         }
     }
@@ -303,8 +302,8 @@
         return mPreviewItemManager.prepareCreateAnimation(destView);
     }
 
-    public void performCreateAnimation(final WorkspaceItemInfo destInfo, final View destView,
-            final WorkspaceItemInfo srcInfo, final DragObject d, Rect dstRect,
+    public void performCreateAnimation(final ItemInfo destInfo, final View destView,
+            final ItemInfo srcInfo, final DragObject d, Rect dstRect,
             float scaleRelativeToDragLayer) {
         final DragView srcView = d.dragView;
         prepareCreateAnimation(destView);
@@ -330,7 +329,7 @@
         mOpenAlarm.cancelAlarm();
     }
 
-    private void onDrop(final WorkspaceItemInfo item, DragObject d, Rect finalRect,
+    private void onDrop(final ItemInfo item, DragObject d, Rect finalRect,
             float scaleRelativeToDragLayer, int index, boolean itemReturnedOnFailedDrop) {
         item.cellX = -1;
         item.cellY = -1;
@@ -361,7 +360,7 @@
             int numItemsInPreview = Math.min(MAX_NUM_ITEMS_IN_PREVIEW, index + 1);
             boolean itemAdded = false;
             if (itemReturnedOnFailedDrop || index >= MAX_NUM_ITEMS_IN_PREVIEW) {
-                List<WorkspaceItemInfo> oldPreviewItems = new ArrayList<>(mCurrentPreviewItems);
+                List<ItemInfo> oldPreviewItems = new ArrayList<>(mCurrentPreviewItems);
                 mInfo.add(item, index, false);
                 mCurrentPreviewItems.clear();
                 mCurrentPreviewItems.addAll(getPreviewItemsOnPage(0));
@@ -422,7 +421,7 @@
             FolderNameInfos nameInfos = new FolderNameInfos();
             Executors.MODEL_EXECUTOR.post(() -> {
                 d.folderNameProvider.getSuggestedFolderName(
-                        getContext(), mInfo.getContents(), nameInfos);
+                        getContext(), mInfo.getAppContents(), nameInfos);
                 postDelayed(() -> {
                     setLabelSuggestion(nameInfos, d.logInstanceId);
                     invalidate();
@@ -475,15 +474,21 @@
 
 
     public void onDrop(DragObject d, boolean itemReturnedOnFailedDrop) {
-        WorkspaceItemInfo item;
+        ItemInfo item;
         if (d.dragInfo instanceof WorkspaceItemFactory) {
             // Came from all apps -- make a copy
             item = ((WorkspaceItemFactory) d.dragInfo).makeWorkspaceItem(getContext());
         } else if (d.dragSource instanceof BaseItemDragListener){
             // Came from a different window -- make a copy
-            item = new WorkspaceItemInfo((WorkspaceItemInfo) d.dragInfo);
+            if (d.dragInfo instanceof AppPairInfo) {
+                // dragged item is app pair
+                item = new AppPairInfo((AppPairInfo) d.dragInfo);
+            } else {
+                // dragged item is WorkspaceItemInfo
+                item = new WorkspaceItemInfo((WorkspaceItemInfo) d.dragInfo);
+            }
         } else {
-            item = (WorkspaceItemInfo) d.dragInfo;
+            item = d.dragInfo;
         }
         mFolder.notifyDrop();
         onDrop(item, d, null, 1.0f,
@@ -665,7 +670,7 @@
     /**
      * Returns the list of items which should be visible in the preview
      */
-    public List<WorkspaceItemInfo> getPreviewItemsOnPage(int page) {
+    public List<ItemInfo> getPreviewItemsOnPage(int page) {
         return mPreviewVerifier.setFolderInfo(mInfo).previewItemsForPage(page, mInfo.getContents());
     }
 
@@ -690,12 +695,12 @@
     /**
      * Updates the preview items which match the provided condition
      */
-    public void updatePreviewItems(Predicate<WorkspaceItemInfo> itemCheck) {
+    public void updatePreviewItems(Predicate<ItemInfo> itemCheck) {
         mPreviewItemManager.updatePreviewItems(itemCheck);
     }
 
     @Override
-    public void onAdd(WorkspaceItemInfo item, int rank) {
+    public void onAdd(ItemInfo item, int rank) {
         updatePreviewItems(false);
         boolean wasDotted = mDotInfo.hasDot();
         mDotInfo.addDotInfo(mActivity.getDotInfoForItem(item));
@@ -707,7 +712,7 @@
     }
 
     @Override
-    public void onRemove(List<WorkspaceItemInfo> items) {
+    public void onRemove(List<ItemInfo> items) {
         updatePreviewItems(false);
         boolean wasDotted = mDotInfo.hasDot();
         items.stream().map(mActivity::getDotInfoForItem).forEach(mDotInfo::subtractDotInfo);
diff --git a/src/com/android/launcher3/folder/FolderPagedView.java b/src/com/android/launcher3/folder/FolderPagedView.java
index f2bed92..8eaa0dc 100644
--- a/src/com/android/launcher3/folder/FolderPagedView.java
+++ b/src/com/android/launcher3/folder/FolderPagedView.java
@@ -41,8 +41,10 @@
 import com.android.launcher3.R;
 import com.android.launcher3.ShortcutAndWidgetContainer;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.keyboard.ViewGroupFocusHelper;
+import com.android.launcher3.model.data.AppPairInfo;
 import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.pageindicators.PageIndicatorDots;
@@ -148,7 +150,7 @@
     /**
      * Binds items to the layout.
      */
-    public void bindItems(List<WorkspaceItemInfo> items) {
+    public void bindItems(List<ItemInfo> items) {
         if (mViewsBound) {
             unbindItems();
         }
@@ -164,8 +166,11 @@
             CellLayout page = (CellLayout) getChildAt(i);
             ShortcutAndWidgetContainer container = page.getShortcutsAndWidgets();
             for (int j = container.getChildCount() - 1; j >= 0; j--) {
-                container.getChildAt(j).setVisibility(View.VISIBLE);
-                mViewCache.recycleView(R.layout.folder_application, container.getChildAt(j));
+                View iconView = container.getChildAt(j);
+                iconView.setVisibility(View.VISIBLE);
+                if (iconView instanceof BubbleTextView) {
+                    mViewCache.recycleView(R.layout.folder_application, iconView);
+                }
             }
             page.removeAllViews();
             mViewCache.recycleView(R.layout.folder_page, page);
@@ -185,7 +190,7 @@
      * Creates and adds an icon corresponding to the provided rank
      * @return the created icon
      */
-    public View createAndAddViewForRank(WorkspaceItemInfo item, int rank) {
+    public View createAndAddViewForRank(ItemInfo item, int rank) {
         View icon = createNewView(item);
         if (!mViewsBound) {
             return icon;
@@ -200,7 +205,7 @@
      * Adds the {@param view} to the layout based on {@param rank} and updated the position
      * related attributes. It assumes that {@param item} is already attached to the view.
      */
-    public void addViewForRank(View view, WorkspaceItemInfo item, int rank) {
+    public void addViewForRank(View view, ItemInfo item, int rank) {
         int pageNo = rank / mOrganizer.getMaxItemsPerPage();
 
         CellLayoutLayoutParams lp = (CellLayoutLayoutParams) view.getLayoutParams();
@@ -209,26 +214,36 @@
     }
 
     @SuppressLint("InflateParams")
-    public View createNewView(WorkspaceItemInfo item) {
+    public View createNewView(ItemInfo item) {
         if (item == null) {
             return null;
         }
-        final BubbleTextView textView = mViewCache.getView(
-                R.layout.folder_application, getContext(), null);
-        textView.applyFromWorkspaceItem(item);
-        textView.setOnClickListener(mFolder.mActivityContext.getItemOnClickListener());
-        textView.setOnLongClickListener(mFolder);
-        textView.setOnFocusChangeListener(mFocusIndicatorHelper);
-        CellLayoutLayoutParams lp = (CellLayoutLayoutParams) textView.getLayoutParams();
+
+        final View icon;
+        if (item instanceof AppPairInfo api) {
+            // TODO (b/332607759): Make view cache work with app pair icons
+            icon = AppPairIcon.inflateIcon(R.layout.folder_app_pair, ActivityContext.lookupContext(
+                    getContext()), null , api, BubbleTextView.DISPLAY_FOLDER);
+        } else {
+            icon = mViewCache.getView(R.layout.folder_application, getContext(), null);
+            ((BubbleTextView) icon).applyFromWorkspaceItem((WorkspaceItemInfo) item);
+        }
+
+        icon.setOnClickListener(mFolder.mActivityContext.getItemOnClickListener());
+        icon.setOnLongClickListener(mFolder);
+        icon.setOnFocusChangeListener(mFocusIndicatorHelper);
+
+        CellLayoutLayoutParams lp = (CellLayoutLayoutParams) icon.getLayoutParams();
         if (lp == null) {
-            textView.setLayoutParams(new CellLayoutLayoutParams(
+            icon.setLayoutParams(new CellLayoutLayoutParams(
                     item.cellX, item.cellY, item.spanX, item.spanY));
         } else {
             lp.setCellX(item.cellX);
             lp.setCellY(item.cellY);
             lp.cellHSpan = lp.cellVSpan = 1;
         }
-        return textView;
+
+        return icon;
     }
 
     @Nullable
@@ -497,13 +512,20 @@
         if (page != null) {
             ShortcutAndWidgetContainer parent = page.getShortcutsAndWidgets();
             for (int i = parent.getChildCount() - 1; i >= 0; i--) {
-                BubbleTextView icon = ((BubbleTextView) parent.getChildAt(i));
-                icon.verifyHighRes();
+                View iconView = parent.getChildAt(i);
+                Drawable d = null;
+                if (iconView instanceof BubbleTextView btv) {
+                    btv.verifyHighRes();
+                    d = btv.getIcon();
+                } else if (iconView instanceof AppPairIcon api) {
+                    api.verifyHighRes();
+                    d = api.getIconDrawableArea().getDrawable();
+                }
+
                 // Set the callback back to the actual icon, in case
                 // it was captured by the FolderIcon
-                Drawable d = icon.getIcon();
                 if (d != null) {
-                    d.setCallback(icon);
+                    d.setCallback(iconView);
                 }
             }
         }
diff --git a/src/com/android/launcher3/folder/LauncherDelegate.java b/src/com/android/launcher3/folder/LauncherDelegate.java
index 33bcf21..07215c4 100644
--- a/src/com/android/launcher3/folder/LauncherDelegate.java
+++ b/src/com/android/launcher3/folder/LauncherDelegate.java
@@ -33,7 +33,7 @@
 import com.android.launcher3.logging.StatsLogManager.StatsLogger;
 import com.android.launcher3.model.ModelWriter;
 import com.android.launcher3.model.data.FolderInfo;
-import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.views.ActivityContext;
 import com.android.launcher3.views.BaseDragLayer;
 
@@ -86,7 +86,7 @@
                 FolderInfo info = folder.mInfo;
                 if (itemCount <= 1) {
                     View newIcon = null;
-                    WorkspaceItemInfo finalItem = null;
+                    ItemInfo finalItem = null;
 
                     if (itemCount == 1) {
                         // Move the item from the folder to the workspace, in the position of the
diff --git a/src/com/android/launcher3/folder/PreviewItemDrawingParams.java b/src/com/android/launcher3/folder/PreviewItemDrawingParams.java
index 58efdc1..0faa1c9 100644
--- a/src/com/android/launcher3/folder/PreviewItemDrawingParams.java
+++ b/src/com/android/launcher3/folder/PreviewItemDrawingParams.java
@@ -17,7 +17,7 @@
 
 import android.graphics.drawable.Drawable;
 
-import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.model.data.ItemInfo;
 
 /**
  * Manages the parameters used to draw a Folder preview item.
@@ -30,7 +30,7 @@
     public FolderPreviewItemAnim anim;
     public boolean hidden;
     public Drawable drawable;
-    public WorkspaceItemInfo item;
+    public ItemInfo item;
 
     PreviewItemDrawingParams(float transX, float transY, float scale) {
         this.transX = transX;
diff --git a/src/com/android/launcher3/folder/PreviewItemManager.java b/src/com/android/launcher3/folder/PreviewItemManager.java
index 9001a0c..6311638 100644
--- a/src/com/android/launcher3/folder/PreviewItemManager.java
+++ b/src/com/android/launcher3/folder/PreviewItemManager.java
@@ -16,6 +16,7 @@
 
 package com.android.launcher3.folder;
 
+import static com.android.launcher3.BubbleTextView.DISPLAY_FOLDER;
 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.ENTER_INDEX;
 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.EXIT_INDEX;
 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.MAX_NUM_ITEMS_IN_PREVIEW;
@@ -41,7 +42,12 @@
 
 import com.android.launcher3.BubbleTextView;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
+import com.android.launcher3.apppairs.AppPairIconDrawingParams;
+import com.android.launcher3.apppairs.AppPairIconGraphic;
 import com.android.launcher3.graphics.PreloadIconDrawable;
+import com.android.launcher3.model.data.AppPairInfo;
+import com.android.launcher3.model.data.ItemInfo;
 import com.android.launcher3.model.data.ItemInfoWithIcon;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.util.Themes;
@@ -125,7 +131,9 @@
     }
 
     Drawable prepareCreateAnimation(final View destView) {
-        Drawable animateDrawable = ((BubbleTextView) destView).getIcon();
+        Drawable animateDrawable = destView instanceof AppPairIcon
+                ? ((AppPairIcon) destView).getIconDrawableArea().getDrawable()
+                : ((BubbleTextView) destView).getIcon();
         computePreviewDrawingParams(animateDrawable.getIntrinsicWidth(),
                 destView.getMeasuredWidth());
         mReferenceDrawable = animateDrawable;
@@ -258,7 +266,7 @@
     }
 
     void buildParamsForPage(int page, ArrayList<PreviewItemDrawingParams> params, boolean animate) {
-        List<WorkspaceItemInfo> items = mIcon.getPreviewItemsOnPage(page);
+        List<ItemInfo> items = mIcon.getPreviewItemsOnPage(page);
 
         // We adjust the size of the list to match the number of items in the preview.
         while (items.size() < params.size()) {
@@ -328,16 +336,18 @@
         mNumOfPrevItems = numOfPrevItemsAux;
     }
 
-    void updatePreviewItems(Predicate<WorkspaceItemInfo> itemCheck) {
+    void updatePreviewItems(Predicate<ItemInfo> itemCheck) {
         boolean modified = false;
         for (PreviewItemDrawingParams param : mFirstPageParams) {
-            if (itemCheck.test(param.item)) {
+            if (itemCheck.test(param.item)
+                    || (param.item instanceof AppPairInfo api && api.anyMatch(itemCheck))) {
                 setDrawable(param, param.item);
                 modified = true;
             }
         }
         for (PreviewItemDrawingParams param : mCurrentPageParams) {
-            if (itemCheck.test(param.item)) {
+            if (itemCheck.test(param.item)
+                    || (param.item instanceof AppPairInfo api && api.anyMatch(itemCheck))) {
                 setDrawable(param, param.item);
                 modified = true;
             }
@@ -370,15 +380,14 @@
      * @param newItems The list of items in the new preview.
      * @param dropped  The item that was dropped onto the FolderIcon.
      */
-    public void onDrop(List<WorkspaceItemInfo> oldItems, List<WorkspaceItemInfo> newItems,
-            WorkspaceItemInfo dropped) {
+    public void onDrop(List<ItemInfo> oldItems, List<ItemInfo> newItems, ItemInfo dropped) {
         int numItems = newItems.size();
         final ArrayList<PreviewItemDrawingParams> params = mFirstPageParams;
         buildParamsForPage(0, params, false);
 
         // New preview items for items that are moving in (except for the dropped item).
-        List<WorkspaceItemInfo> moveIn = new ArrayList<>();
-        for (WorkspaceItemInfo newItem : newItems) {
+        List<ItemInfo> moveIn = new ArrayList<>();
+        for (ItemInfo newItem : newItems) {
             if (!oldItems.contains(newItem) && !newItem.equals(dropped)) {
                 moveIn.add(newItem);
             }
@@ -401,10 +410,10 @@
         }
 
         // Old preview items that need to be moved out.
-        List<WorkspaceItemInfo> moveOut = new ArrayList<>(oldItems);
+        List<ItemInfo> moveOut = new ArrayList<>(oldItems);
         moveOut.removeAll(newItems);
         for (int i = 0; i < moveOut.size(); ++i) {
-            WorkspaceItemInfo item = moveOut.get(i);
+            ItemInfo item = moveOut.get(i);
             int oldIndex = oldItems.indexOf(item);
             PreviewItemDrawingParams p = computePreviewItemDrawingParams(oldIndex, numItems, null);
             updateTransitionParam(p, item, oldIndex, EXIT_INDEX, numItems);
@@ -418,7 +427,7 @@
         }
     }
 
-    private void updateTransitionParam(final PreviewItemDrawingParams p, WorkspaceItemInfo item,
+    private void updateTransitionParam(final PreviewItemDrawingParams p, ItemInfo item,
             int prevIndex, int newIndex, int numItems) {
         setDrawable(p, item);
 
@@ -431,16 +440,24 @@
     }
 
     @VisibleForTesting
-    public void setDrawable(PreviewItemDrawingParams p, WorkspaceItemInfo item) {
-        if (item.hasPromiseIconUi() || (item.runtimeStatusFlags
-                & ItemInfoWithIcon.FLAG_SHOW_DOWNLOAD_PROGRESS_MASK) != 0) {
-            PreloadIconDrawable drawable = newPendingIcon(mContext, item);
-            p.drawable = drawable;
-        } else {
-            p.drawable = item.newIcon(mContext,
-                    Themes.isThemedIconEnabled(mContext) ? FLAG_THEMED : 0);
+    public void setDrawable(PreviewItemDrawingParams p, ItemInfo item) {
+        if (item instanceof WorkspaceItemInfo wii) {
+            if (wii.hasPromiseIconUi() || (wii.runtimeStatusFlags
+                    & ItemInfoWithIcon.FLAG_SHOW_DOWNLOAD_PROGRESS_MASK) != 0) {
+                PreloadIconDrawable drawable = newPendingIcon(mContext, wii);
+                p.drawable = drawable;
+            } else {
+                p.drawable = wii.newIcon(mContext,
+                        Themes.isThemedIconEnabled(mContext) ? FLAG_THEMED : 0);
+            }
+            p.drawable.setBounds(0, 0, mIconSize, mIconSize);
+        } else if (item instanceof AppPairInfo api) {
+            AppPairIconDrawingParams appPairParams =
+                    new AppPairIconDrawingParams(mContext, DISPLAY_FOLDER);
+            p.drawable = AppPairIconGraphic.composeDrawable(api, appPairParams);
+            p.drawable.setBounds(0, 0, mIconSize, mIconSize);
         }
-        p.drawable.setBounds(0, 0, mIconSize, mIconSize);
+
         p.item = item;
         // Set the callback to FolderIcon as it is responsible to drawing the icon. The
         // callback will be released when the folder is opened.
diff --git a/src/com/android/launcher3/model/BgDataModel.java b/src/com/android/launcher3/model/BgDataModel.java
index ee9ce7d..886ae27 100644
--- a/src/com/android/launcher3/model/BgDataModel.java
+++ b/src/com/android/launcher3/model/BgDataModel.java
@@ -44,6 +44,7 @@
 import com.android.launcher3.Workspace;
 import com.android.launcher3.config.FeatureFlags;
 import com.android.launcher3.model.data.AppInfo;
+import com.android.launcher3.model.data.AppPairInfo;
 import com.android.launcher3.model.data.CollectionInfo;
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.ItemInfo;
@@ -259,10 +260,15 @@
         itemsIdMap.put(item.id, item);
         switch (item.itemType) {
             case LauncherSettings.Favorites.ITEM_TYPE_FOLDER:
-            case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR:
-                collections.put(item.id, (CollectionInfo) item);
+                collections.put(item.id, (FolderInfo) item);
                 workspaceItems.add(item);
                 break;
+            case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR:
+                collections.put(item.id, (AppPairInfo) item);
+                // Fall through here. App pairs are both containers (like folders) and containable
+                // items (can be placed in folders). So we need to add app pairs to the folders
+                // array (above) but also verify the existence of their container, like regular
+                // apps (below).
             case LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT:
             case LauncherSettings.Favorites.ITEM_TYPE_APPLICATION:
                 if (item.container == LauncherSettings.Favorites.CONTAINER_DESKTOP ||
@@ -277,7 +283,7 @@
                             Log.e(TAG, msg);
                         }
                     } else {
-                        findOrMakeFolder(item.container).add((WorkspaceItemInfo) item);
+                        findOrMakeFolder(item.container).add(item);
                     }
                 }
                 break;
diff --git a/src/com/android/launcher3/model/FirstScreenBroadcast.java b/src/com/android/launcher3/model/FirstScreenBroadcast.java
index 1deb665..cc20cd1 100644
--- a/src/com/android/launcher3/model/FirstScreenBroadcast.java
+++ b/src/com/android/launcher3/model/FirstScreenBroadcast.java
@@ -115,7 +115,7 @@
         for (ItemInfo info : firstScreenItems) {
             if (info instanceof CollectionInfo ci) {
                 String collectionItemInfoPackage;
-                for (ItemInfo collectionItemInfo : cloneOnMainThread(ci.getContents())) {
+                for (ItemInfo collectionItemInfo : cloneOnMainThread(ci.getAppContents())) {
                     collectionItemInfoPackage = getPackageName(collectionItemInfo);
                     if (collectionItemInfoPackage != null
                             && packages.contains(collectionItemInfoPackage)) {
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index f8c76a3..3a23765 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -81,7 +81,6 @@
 import com.android.launcher3.model.data.FolderInfo;
 import com.android.launcher3.model.data.IconRequestInfo;
 import com.android.launcher3.model.data.ItemInfo;
-import com.android.launcher3.model.data.ItemInfoWithIcon;
 import com.android.launcher3.model.data.LauncherAppWidgetInfo;
 import com.android.launcher3.model.data.WorkspaceItemInfo;
 import com.android.launcher3.pm.InstallSessionHelper;
@@ -494,9 +493,7 @@
             }
 
             appPair.getContents().sort(Folder.ITEM_POS_COMPARATOR);
-            // Fetch hi-res icons if needed.
-            appPair.getContents().stream().filter(ItemInfoWithIcon::usingLowResIcon)
-                    .forEach(member -> mIconCache.getTitleAndIcon(member, false));
+            appPair.fetchHiResIconsIfNeeded(mIconCache);
         }
     }
 
@@ -566,12 +563,16 @@
             // Ranks are the source of truth for folder items, so cellX and cellY can be
             // ignored for now. Database will be updated once user manually modifies folder.
             for (int rank = 0; rank < size; ++rank) {
-                WorkspaceItemInfo info = folder.getContents().get(rank);
+                ItemInfo info = folder.getContents().get(rank);
                 info.rank = rank;
 
-                if (info.usingLowResIcon() && info.itemType == Favorites.ITEM_TYPE_APPLICATION
+                if (info instanceof WorkspaceItemInfo wii
+                        && wii.usingLowResIcon()
+                        && wii.itemType == Favorites.ITEM_TYPE_APPLICATION
                         && verifiers.stream().anyMatch(it -> it.isItemInPreview(info.rank))) {
-                    mIconCache.getTitleAndIcon(info, false);
+                    mIconCache.getTitleAndIcon(wii, false);
+                } else if (info instanceof AppPairInfo api) {
+                    api.fetchHiResIconsIfNeeded(mIconCache);
                 }
             }
         }
@@ -788,7 +789,7 @@
                 FolderNameInfos suggestionInfos = new FolderNameInfos();
                 CollectionInfo info = mBgDataModel.collections.valueAt(i);
                 if (info instanceof FolderInfo fi && fi.suggestedFolderNames == null) {
-                    provider.getSuggestedFolderName(mApp.getContext(), fi.getContents(),
+                    provider.getSuggestedFolderName(mApp.getContext(), fi.getAppContents(),
                             suggestionInfos);
                     fi.suggestedFolderNames = suggestionInfos;
                 }
diff --git a/src/com/android/launcher3/model/WorkspaceItemProcessor.kt b/src/com/android/launcher3/model/WorkspaceItemProcessor.kt
index ba612cc..1093753 100644
--- a/src/com/android/launcher3/model/WorkspaceItemProcessor.kt
+++ b/src/com/android/launcher3/model/WorkspaceItemProcessor.kt
@@ -376,7 +376,7 @@
             val folderInfo: FolderInfo = collection
             val newAppPair = AppPairInfo()
             // Move the placeholder's contents over to the new app pair.
-            folderInfo.contents.forEach(newAppPair::add)
+            folderInfo.getContents().forEach(newAppPair::add)
             collection = newAppPair
             // Remove the placeholder and add the app pair into the data model.
             bgDataModel.collections.remove(c.id)
diff --git a/src/com/android/launcher3/model/data/AppPairInfo.kt b/src/com/android/launcher3/model/data/AppPairInfo.kt
index 4081316..a56ee8e 100644
--- a/src/com/android/launcher3/model/data/AppPairInfo.kt
+++ b/src/com/android/launcher3/model/data/AppPairInfo.kt
@@ -18,11 +18,14 @@
 
 import android.content.Context
 import com.android.launcher3.LauncherSettings
+import com.android.launcher3.icons.IconCache
 import com.android.launcher3.logger.LauncherAtom
 import com.android.launcher3.views.ActivityContext
 
 /** A type of app collection that launches multiple apps into split screen. */
 class AppPairInfo() : CollectionInfo() {
+    private var contents: ArrayList<WorkspaceItemInfo> = ArrayList()
+
     init {
         itemType = LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR
     }
@@ -33,11 +36,28 @@
         add(app2)
     }
 
-    /** Adds an element to the contents array. */
-    override fun add(item: WorkspaceItemInfo) {
+    /** Creates a new AppPairInfo that is a copy of the provided one. */
+    constructor(appPairInfo: AppPairInfo) : this() {
+        contents = appPairInfo.contents.clone() as ArrayList<WorkspaceItemInfo>
+        copyFrom(appPairInfo)
+    }
+
+    /** Adds an element to the contents ArrayList. */
+    override fun add(item: ItemInfo) {
+        if (item !is WorkspaceItemInfo) {
+            throw RuntimeException("tried to add an illegal type into an app pair")
+        }
+
         contents.add(item)
     }
 
+    /** Returns the app pair's member apps as an ArrayList of [ItemInfo]. */
+    override fun getContents(): ArrayList<ItemInfo> =
+        ArrayList(contents.stream().map { it as ItemInfo }.toList())
+
+    /** Returns the app pair's member apps as an ArrayList of [WorkspaceItemInfo]. */
+    override fun getAppContents(): ArrayList<WorkspaceItemInfo> = contents
+
     /** Returns the first app in the pair. */
     fun getFirstApp() = contents[0]
 
@@ -50,7 +70,9 @@
     /** Checks if the app pair is launchable at the current screen size. */
     fun isLaunchable(context: Context) =
         (ActivityContext.lookupContext(context) as ActivityContext).getDeviceProfile().isTablet ||
-            noneMatch { it.hasStatusFlag(WorkspaceItemInfo.FLAG_NON_RESIZEABLE) }
+            getAppContents().stream().noneMatch {
+                it.hasStatusFlag(WorkspaceItemInfo.FLAG_NON_RESIZEABLE)
+            }
 
     /** Generates an ItemInfo for logging. */
     override fun buildProto(cInfo: CollectionInfo?): LauncherAtom.ItemInfo {
@@ -62,4 +84,11 @@
             .setContainerInfo(getContainerInfo())
             .build()
     }
+
+    /** Fetches high-res icons for member apps if needed. */
+    fun fetchHiResIconsIfNeeded(iconCache: IconCache) {
+        getAppContents().stream().filter(ItemInfoWithIcon::usingLowResIcon).forEach { member ->
+            iconCache.getTitleAndIcon(member, false)
+        }
+    }
 }
diff --git a/src/com/android/launcher3/model/data/CollectionInfo.kt b/src/com/android/launcher3/model/data/CollectionInfo.kt
index 2b865a5..4f5e12f 100644
--- a/src/com/android/launcher3/model/data/CollectionInfo.kt
+++ b/src/com/android/launcher3/model/data/CollectionInfo.kt
@@ -22,19 +22,21 @@
 import java.util.function.Predicate
 
 abstract class CollectionInfo : ItemInfo() {
-    var contents: ArrayList<WorkspaceItemInfo> = ArrayList()
+    /** Adds an ItemInfo to the collection. Throws if given an illegal type. */
+    abstract fun add(item: ItemInfo)
 
-    abstract fun add(item: WorkspaceItemInfo)
+    /** Returns the collection's contents as an ArrayList of [ItemInfo]. */
+    abstract fun getContents(): ArrayList<ItemInfo>
+
+    /**
+     * Returns the collection's contents as an ArrayList of [WorkspaceItemInfo]. Does not include
+     * other collection [ItemInfo]s that are inside this collection; rather, it should collect
+     * *their* contents and adds them to the ArrayList.
+     */
+    abstract fun getAppContents(): ArrayList<WorkspaceItemInfo>
 
     /** Convenience function. Checks contents to see if any match a given predicate. */
-    fun anyMatch(matcher: Predicate<in WorkspaceItemInfo>): Boolean {
-        return contents.stream().anyMatch(matcher)
-    }
-
-    /** Convenience function. Returns true if none of the contents match a given predicate. */
-    fun noneMatch(matcher: Predicate<in WorkspaceItemInfo>): Boolean {
-        return contents.stream().noneMatch(matcher)
-    }
+    fun anyMatch(matcher: Predicate<ItemInfo>) = getContents().stream().anyMatch(matcher)
 
     override fun onAddToDatabase(writer: ContentWriter) {
         super.onAddToDatabase(writer)
diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java
index 1bbb2fe..56996ef 100644
--- a/src/com/android/launcher3/model/data/FolderInfo.java
+++ b/src/com/android/launcher3/model/data/FolderInfo.java
@@ -29,6 +29,7 @@
 
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.folder.Folder;
 import com.android.launcher3.folder.FolderNameInfos;
 import com.android.launcher3.logger.LauncherAtom;
 import com.android.launcher3.logger.LauncherAtom.Attribute;
@@ -98,14 +99,20 @@
 
     public FolderNameInfos suggestedFolderNames;
 
+    /**
+     * The apps and shortcuts
+     */
+    private final ArrayList<ItemInfo> contents = new ArrayList<>();
+
     private ArrayList<FolderListener> mListeners = new ArrayList<>();
 
     public FolderInfo() {
         itemType = LauncherSettings.Favorites.ITEM_TYPE_FOLDER;
     }
 
-    /** Adds a app or shortcut to the contents array without animation. */
-    public void add(@NonNull WorkspaceItemInfo item) {
+    /** Adds a app or shortcut to the contents ArrayList without animation. */
+    @Override
+    public void add(@NonNull ItemInfo item) {
         add(item, false /* animate */);
     }
 
@@ -114,14 +121,18 @@
      *
      * @param item
      */
-    public void add(WorkspaceItemInfo item, boolean animate) {
+    public void add(ItemInfo item, boolean animate) {
         add(item, getContents().size(), animate);
     }
 
     /**
      * Add an app or shortcut for a specified rank.
      */
-    public void add(WorkspaceItemInfo item, int rank, boolean animate) {
+    public void add(ItemInfo item, int rank, boolean animate) {
+        if (!Folder.willAccept(item)) {
+            throw new RuntimeException("tried to add an illegal type into a folder");
+        }
+
         rank = Utilities.boundToRange(rank, 0, getContents().size());
         getContents().add(rank, item);
         for (int i = 0; i < mListeners.size(); i++) {
@@ -135,21 +146,49 @@
      *
      * @param item
      */
-    public void remove(WorkspaceItemInfo item, boolean animate) {
+    public void remove(ItemInfo item, boolean animate) {
         removeAll(Collections.singletonList(item), animate);
     }
 
     /**
      * Remove all matching app or shortcut. Does not change the DB.
      */
-    public void removeAll(List<WorkspaceItemInfo> items, boolean animate) {
-        getContents().removeAll(items);
+    public void removeAll(List<ItemInfo> items, boolean animate) {
+        contents.removeAll(items);
         for (int i = 0; i < mListeners.size(); i++) {
             mListeners.get(i).onRemove(items);
         }
         itemsChanged(animate);
     }
 
+    /**
+     * Returns the folder's contents as an ArrayList of {@link ItemInfo}. Includes
+     * {@link WorkspaceItemInfo} and {@link AppPairInfo}s.
+     */
+    @NonNull
+    @Override
+    public ArrayList<ItemInfo> getContents() {
+        return contents;
+    }
+
+    /**
+     * Returns the folder's contents as an ArrayList of {@link WorkspaceItemInfo}. Note: Does not
+     * return any {@link AppPairInfo}s contained in the folder, instead collects *their* contents
+     * and adds them to the ArrayList.
+     */
+    @Override
+    public ArrayList<WorkspaceItemInfo> getAppContents()  {
+        ArrayList<WorkspaceItemInfo> workspaceItemInfos = new ArrayList<>();
+        for (ItemInfo item : contents) {
+            if (item instanceof WorkspaceItemInfo wii) {
+                workspaceItemInfos.add(wii);
+            } else if (item instanceof AppPairInfo api) {
+                workspaceItemInfos.addAll(api.getAppContents());
+            }
+        }
+        return workspaceItemInfos;
+    }
+
     @Override
     public void onAddToDatabase(@NonNull ContentWriter writer) {
         super.onAddToDatabase(writer);
@@ -171,8 +210,8 @@
     }
 
     public interface FolderListener {
-        void onAdd(WorkspaceItemInfo item, int rank);
-        void onRemove(List<WorkspaceItemInfo> item);
+        void onAdd(ItemInfo item, int rank);
+        void onRemove(List<ItemInfo> item);
         void onItemsChanged(boolean animate);
     }
 
@@ -263,10 +302,17 @@
     public ItemInfo makeShallowCopy() {
         FolderInfo folderInfo = new FolderInfo();
         folderInfo.copyFrom(this);
-        folderInfo.setContents(this.getContents());
         return folderInfo;
     }
 
+    @Override
+    public void copyFrom(@NonNull ItemInfo info) {
+        super.copyFrom(info);
+        if (info instanceof FolderInfo fi) {
+            contents.addAll(fi.getContents());
+        }
+    }
+
     /**
      * Returns index of the accepted suggestion.
      */
diff --git a/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt b/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
index 3a1883c..da14425 100644
--- a/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
+++ b/tests/src/com/android/launcher3/folder/PreviewItemManagerTest.kt
@@ -29,7 +29,7 @@
 import com.android.launcher3.icons.FastBitmapDrawable
 import com.android.launcher3.icons.UserBadgeDrawable
 import com.android.launcher3.model.data.FolderInfo
-import com.android.launcher3.model.data.WorkspaceItemInfo
+import com.android.launcher3.model.data.ItemInfo
 import com.android.launcher3.util.ActivityContextWrapper
 import com.android.launcher3.util.FlagOp
 import com.android.launcher3.util.LauncherLayoutBuilder
@@ -47,7 +47,7 @@
 
     private lateinit var previewItemManager: PreviewItemManager
     private lateinit var context: Context
-    private lateinit var folderItems: ArrayList<WorkspaceItemInfo>
+    private lateinit var folderItems: ArrayList<ItemInfo>
     private lateinit var modelHelper: LauncherModelHelper
     private lateinit var folderIcon: FolderIcon
 
@@ -72,15 +72,17 @@
                     .build()
             )
             .loadModelSync()
-        folderItems = modelHelper.bgDataModel.collections.valueAt(0).contents
+        folderItems = modelHelper.bgDataModel.collections.valueAt(0).getContents()
         folderIcon.mInfo = modelHelper.bgDataModel.collections.valueAt(0) as FolderInfo
-        folderIcon.mInfo.contents = folderItems
+        folderIcon.mInfo.getContents().addAll(folderItems)
 
+        // Use getAppContents() to "cast" contents to WorkspaceItemInfo so we can set bitmaps
+        val folderApps = modelHelper.bgDataModel.collections.valueAt(0).getAppContents()
         // Set first icon to be themed.
-        folderItems[0]
+        folderApps[0]
             .bitmap
             .setMonoIcon(
-                folderItems[0].bitmap.icon,
+                folderApps[0].bitmap.icon,
                 BaseIconFactory(
                     context,
                     context.resources.configuration.densityDpi,
@@ -89,7 +91,7 @@
             )
 
         // Set second icon to be non-themed.
-        folderItems[1]
+        folderApps[1]
             .bitmap
             .setMonoIcon(
                 null,
@@ -101,23 +103,21 @@
             )
 
         // Set third icon to be themed with badge.
-        folderItems[2]
+        folderApps[2]
             .bitmap
             .setMonoIcon(
-                folderItems[2].bitmap.icon,
+                folderApps[2].bitmap.icon,
                 BaseIconFactory(
                     context,
                     context.resources.configuration.densityDpi,
                     previewItemManager.mIconSize
                 )
             )
-        folderItems[2].bitmap =
-            folderItems[2].bitmap.withFlags(profileFlagOp(UserIconInfo.TYPE_WORK))
+        folderApps[2].bitmap = folderApps[2].bitmap.withFlags(profileFlagOp(UserIconInfo.TYPE_WORK))
 
         // Set fourth icon to be non-themed with badge.
-        folderItems[3].bitmap =
-            folderItems[3].bitmap.withFlags(profileFlagOp(UserIconInfo.TYPE_WORK))
-        folderItems[3]
+        folderApps[3].bitmap = folderApps[3].bitmap.withFlags(profileFlagOp(UserIconInfo.TYPE_WORK))
+        folderApps[3]
             .bitmap
             .setMonoIcon(
                 null,
diff --git a/tests/src/com/android/launcher3/model/CacheDataUpdatedTaskTest.java b/tests/src/com/android/launcher3/model/CacheDataUpdatedTaskTest.java
index abb0c39..d3a6355 100644
--- a/tests/src/com/android/launcher3/model/CacheDataUpdatedTaskTest.java
+++ b/tests/src/com/android/launcher3/model/CacheDataUpdatedTaskTest.java
@@ -160,6 +160,6 @@
     }
 
     private List<WorkspaceItemInfo> allItems() {
-        return ((FolderInfo) mModelHelper.getBgDataModel().itemsIdMap.get(1)).getContents();
+        return ((FolderInfo) mModelHelper.getBgDataModel().itemsIdMap.get(1)).getAppContents();
     }
 }
diff --git a/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt b/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
index ed587a1..2e209a4 100644
--- a/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
+++ b/tests/src/com/android/launcher3/model/FolderIconLoadTest.kt
@@ -168,8 +168,8 @@
         val collections = modelHelper.getBgDataModel().collections
 
         assertThat(collections.size()).isEqualTo(1)
-        assertThat(collections.valueAt(0).contents.size).isEqualTo(itemCount)
-        return collections.valueAt(0).contents
+        assertThat(collections.valueAt(0).getAppContents().size).isEqualTo(itemCount)
+        return collections.valueAt(0).getAppContents()
     }
 
     private fun verifyHighRes(items: ArrayList<WorkspaceItemInfo>, vararg indices: Int) {
diff --git a/tests/src/com/android/launcher3/util/ItemInflaterTest.kt b/tests/src/com/android/launcher3/util/ItemInflaterTest.kt
index 0065527..dae09bb 100644
--- a/tests/src/com/android/launcher3/util/ItemInflaterTest.kt
+++ b/tests/src/com/android/launcher3/util/ItemInflaterTest.kt
@@ -139,9 +139,9 @@
     @Test
     fun test_folder_inflated_on_UI() {
         val itemInfo = FolderInfo()
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
 
         val view =
             MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get()
@@ -155,9 +155,9 @@
         setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION)
 
         val itemInfo = FolderInfo()
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
 
         val view =
             VIEW_PREINFLATION_EXECUTOR.submit(
@@ -173,8 +173,8 @@
     fun test_app_pair_inflated_on_UI() {
         val itemInfo = AppPairInfo()
         itemInfo.itemType = ITEM_TYPE_APP_PAIR
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
 
         val view =
             MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get()
@@ -189,8 +189,8 @@
 
         val itemInfo = AppPairInfo()
         itemInfo.itemType = ITEM_TYPE_APP_PAIR
-        itemInfo.contents.add(workspaceItemInfo())
-        itemInfo.contents.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
+        itemInfo.add(workspaceItemInfo())
 
         val view =
             VIEW_PREINFLATION_EXECUTOR.submit(