App Pairs: Implement save, inflate, launch, and delete

This is the third of several patches implementing the App Pairs feature behind a flag.

This patch includes:
- AppPairIcon and associated XML. Actual icon asset is placeholder for now
- Ability to launch split pair on click
- Icon can be moved around, incl. to Taskbar
- App pair can be deleted by dragging to "Remove" drop zone
- Icon persists on Launcher reload

Change-Id: I88aec6fbc814be98f9ef048bbc5af889d0797970
Flag: ENABLE_APP_PAIRS (set to false)
Bug: 274835596
Test: Not included in this CL, but will follow
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index bfbd660..93c0e7e 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -145,6 +145,7 @@
 import com.android.launcher3.allapps.BaseSearchConfig;
 import com.android.launcher3.allapps.DiscoveryBounce;
 import com.android.launcher3.anim.PropertyListBuilder;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.celllayout.CellPosMapper.CellPos;
 import com.android.launcher3.celllayout.CellPosMapper.TwoPanelCellPosMapper;
@@ -2445,9 +2446,9 @@
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR: {
-                    FolderInfo info = (FolderInfo) item;
-                    // TODO (jeremysim b/274189428): Create app pair icon
-                    view = null;
+                    view = AppPairIcon.inflateIcon(R.layout.app_pair_icon, this,
+                            (ViewGroup) workspace.getChildAt(workspace.getCurrentPage()),
+                            (FolderInfo) item);
                     break;
                 }
                 case LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET:
@@ -3389,4 +3390,12 @@
     public View.OnLongClickListener getAllAppsItemLongClickListener() {
         return ItemLongClickListener.INSTANCE_ALL_APPS;
     }
+
+    /**
+     * Handles an app pair launch; overridden in
+     * {@link com.android.launcher3.uioverrides.QuickstepLauncher}
+     */
+    public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
+        // Overridden
+    }
 }
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
new file mode 100644
index 0000000..1dc4ad2
--- /dev/null
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 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.launcher3.apppairs;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+
+import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.R;
+import com.android.launcher3.dragndrop.DraggableView;
+import com.android.launcher3.model.data.FolderInfo;
+import com.android.launcher3.model.data.WorkspaceItemInfo;
+import com.android.launcher3.views.ActivityContext;
+
+import java.util.Collections;
+import java.util.Comparator;
+
+/**
+ * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace.
+ */
+public class AppPairIcon extends FrameLayout implements DraggableView {
+
+    private ActivityContext mActivity;
+    private BubbleTextView mAppPairName;
+    private FolderInfo mInfo;
+
+    public AppPairIcon(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public AppPairIcon(Context context) {
+        super(context);
+    }
+
+    /**
+     * Builds an AppPairIcon to be added to the Launcher
+     */
+    public static AppPairIcon inflateIcon(int resId, ActivityContext activity,
+            @Nullable ViewGroup group, FolderInfo appPairInfo) {
+
+        LayoutInflater inflater = (group != null)
+                ? LayoutInflater.from(group.getContext())
+                : activity.getLayoutInflater();
+        AppPairIcon icon = (AppPairIcon) inflater.inflate(resId, group, false);
+
+        // Sort contents, so that left-hand app comes first
+        Collections.sort(appPairInfo.contents, Comparator.comparingInt(a -> a.rank));
+
+        icon.setClipToPadding(false);
+        icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name);
+
+        // TODO (jeremysim b/274189428): Replace this placeholder icon
+        WorkspaceItemInfo placeholder = new WorkspaceItemInfo();
+        placeholder.newIcon(icon.getContext());
+        icon.mAppPairName.applyFromWorkspaceItem(placeholder);
+
+        icon.mAppPairName.setText(appPairInfo.title);
+
+        icon.setTag(appPairInfo);
+        icon.setOnClickListener(activity.getItemOnClickListener());
+        icon.mInfo = appPairInfo;
+        icon.mActivity = activity;
+
+        icon.setAccessibilityDelegate(activity.getAccessibilityDelegate());
+
+        return icon;
+    }
+
+    @Override
+    public int getViewType() {
+        return DRAGGABLE_ICON;
+    }
+
+    @Override
+    public void getWorkspaceVisualDragBounds(Rect bounds) {
+        mAppPairName.getIconBounds(bounds);
+    }
+
+    public FolderInfo getInfo() {
+        return mInfo;
+    }
+}
diff --git a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
index 7241b17..68106c4 100644
--- a/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
+++ b/src/com/android/launcher3/graphics/LauncherPreviewRenderer.java
@@ -51,6 +51,7 @@
 import android.view.ViewGroup;
 import android.view.WindowInsets;
 import android.view.WindowManager;
+import android.widget.FrameLayout;
 import android.widget.TextClock;
 
 import androidx.annotation.NonNull;
@@ -70,6 +71,7 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.Workspace;
 import com.android.launcher3.WorkspaceLayoutManager;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
 import com.android.launcher3.celllayout.CellPosMapper;
 import com.android.launcher3.config.FeatureFlags;
@@ -358,12 +360,13 @@
         addInScreenFromBind(icon, info);
     }
 
-    private void inflateAndAddFolder(FolderInfo info) {
+    private void inflateAndAddCollectionIcon(FolderInfo info) {
         CellLayout screen = info.container == Favorites.CONTAINER_DESKTOP
                 ? mWorkspaceScreens.get(info.screenId)
                 : mHotseat;
-        FolderIcon folderIcon = FolderIcon.inflateIcon(R.layout.folder_icon, this, screen,
-                info);
+        FrameLayout folderIcon = info.itemType == Favorites.ITEM_TYPE_FOLDER
+                ? FolderIcon.inflateIcon(R.layout.folder_icon, this, screen, info)
+                : AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, screen, info);
         addInScreenFromBind(folderIcon, info);
     }
 
@@ -467,7 +470,8 @@
                     inflateAndAddIcon((WorkspaceItemInfo) itemInfo);
                     break;
                 case Favorites.ITEM_TYPE_FOLDER:
-                    inflateAndAddFolder((FolderInfo) itemInfo);
+                case Favorites.ITEM_TYPE_APP_PAIR:
+                    inflateAndAddCollectionIcon((FolderInfo) itemInfo);
                     break;
                 default:
                     break;
diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java
index 2a7cd9a..8bb06c1 100644
--- a/src/com/android/launcher3/logging/StatsLogManager.java
+++ b/src/com/android/launcher3/logging/StatsLogManager.java
@@ -641,11 +641,17 @@
         @UiEvent(doc = "User has swiped upwards from the gesture handle to show transient taskbar.")
         LAUNCHER_TRANSIENT_TASKBAR_SHOW(1331),
 
+        @UiEvent(doc = "User has clicked an app pair and launched directly into split screen.")
+        LAUNCHER_APP_PAIR_LAUNCH(1374),
+
+        @UiEvent(doc = "User saved an app pair.")
+        LAUNCHER_APP_PAIR_SAVE(1456),
+
         @UiEvent(doc = "App launched through pending intent")
-        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394),
-        ;
+        LAUNCHER_APP_LAUNCH_PENDING_INTENT(1394)
 
         // ADD MORE
+        ;
 
         private final int mId;
 
diff --git a/src/com/android/launcher3/model/LoaderTask.java b/src/com/android/launcher3/model/LoaderTask.java
index 787ac38..933468c 100644
--- a/src/com/android/launcher3/model/LoaderTask.java
+++ b/src/com/android/launcher3/model/LoaderTask.java
@@ -690,9 +690,11 @@
                     break;
 
                 case Favorites.ITEM_TYPE_FOLDER:
+                case Favorites.ITEM_TYPE_APP_PAIR:
                     FolderInfo folderInfo = mBgDataModel.findOrMakeFolder(c.id);
                     c.applyCommonProperties(folderInfo);
 
+                    folderInfo.itemType = c.itemType;
                     // Do not trim the folder label, as is was set by the user.
                     folderInfo.title = c.getString(c.mTitleIndex);
                     folderInfo.spanX = 1;
diff --git a/src/com/android/launcher3/model/ModelWriter.java b/src/com/android/launcher3/model/ModelWriter.java
index a6b4d59..2358a9f 100644
--- a/src/com/android/launcher3/model/ModelWriter.java
+++ b/src/com/android/launcher3/model/ModelWriter.java
@@ -489,6 +489,7 @@
                         case Favorites.ITEM_TYPE_APPLICATION:
                         case Favorites.ITEM_TYPE_DEEP_SHORTCUT:
                         case Favorites.ITEM_TYPE_FOLDER:
+                        case Favorites.ITEM_TYPE_APP_PAIR:
                             if (!mBgDataModel.workspaceItems.contains(modelItem)) {
                                 mBgDataModel.workspaceItems.add(modelItem);
                             }
diff --git a/src/com/android/launcher3/model/data/FolderInfo.java b/src/com/android/launcher3/model/data/FolderInfo.java
index e5a0eb1..9bf6d43 100644
--- a/src/com/android/launcher3/model/data/FolderInfo.java
+++ b/src/com/android/launcher3/model/data/FolderInfo.java
@@ -119,8 +119,8 @@
     public static FolderInfo createAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) {
         FolderInfo newAppPair = new FolderInfo();
         newAppPair.itemType = LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR;
-        newAppPair.contents.add(app1);
-        newAppPair.contents.add(app2);
+        newAppPair.add(app1, /* animate */ false);
+        newAppPair.add(app2, /* animate */ false);
         return newAppPair;
     }
 
diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java
index 790c226..8c12547 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -42,6 +42,7 @@
 import com.android.launcher3.LauncherSettings;
 import com.android.launcher3.R;
 import com.android.launcher3.Utilities;
+import com.android.launcher3.apppairs.AppPairIcon;
 import com.android.launcher3.folder.Folder;
 import com.android.launcher3.folder.FolderIcon;
 import com.android.launcher3.logging.InstanceId;
@@ -95,6 +96,8 @@
         } else if (tag instanceof FolderInfo) {
             if (v instanceof FolderIcon) {
                 onClickFolderIcon(v);
+            } else if (v instanceof AppPairIcon) {
+                onClickAppPairIcon(v);
             }
         } else if (tag instanceof AppInfo) {
             startAppShortcutOrInfoActivity(v, (AppInfo) tag, launcher);
@@ -123,6 +126,17 @@
     }
 
     /**
+     * Event handler for an app pair icon click.
+     *
+     * @param v The view that was clicked. Must be an instance of {@link AppPairIcon}.
+     */
+    private static void onClickAppPairIcon(View v) {
+        Launcher launcher = Launcher.getLauncher(v.getContext());
+        FolderInfo folderInfo = ((AppPairIcon) v).getInfo();
+        launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1));
+    }
+
+    /**
      * Event handler for the app widget view which has not fully restored.
      */
     private static void onClickPendingWidget(PendingAppWidgetHostView v, Launcher launcher) {