Merge "App Pairs: Implement app pairs icon" into main
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 37bd4f1..57163ff 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -250,6 +250,10 @@
     <!-- Folder name format when folder has 4 or more items shown in preview-->
     <string name="folder_name_format_overflow">Folder: <xliff:g id="name" example="Games">%1$s</xliff:g>, <xliff:g id="size" example="2">%2$d</xliff:g> or more items</string>
 
+    <!-- App pair accessibility -->
+    <!-- App pair name -->
+    <string name="app_pair_name_format">App pair: <xliff:g id="app1" example="Chrome">%1$s</xliff:g> and <xliff:g id="app2" example="YouTube">%2$s</xliff:g></string>
+
     <!-- Strings for the customization mode -->
     <!-- Text for wallpaper change button [CHAR LIMIT=30]-->
     <string name="styles_wallpaper_button_text">Wallpaper &amp; style</string>
diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java
index 1dc4ad2..8121245 100644
--- a/src/com/android/launcher3/apppairs/AppPairIcon.java
+++ b/src/com/android/launcher3/apppairs/AppPairIcon.java
@@ -17,7 +17,9 @@
 package com.android.launcher3.apppairs;
 
 import android.content.Context;
+import android.graphics.Canvas;
 import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
@@ -26,6 +28,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.launcher3.BubbleTextView;
+import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.R;
 import com.android.launcher3.dragndrop.DraggableView;
 import com.android.launcher3.model.data.FolderInfo;
@@ -37,11 +40,41 @@
 
 /**
  * A {@link android.widget.FrameLayout} used to represent an app pair icon on the workspace.
+ * <br>
+ * The app pair icon is two parallel background rectangles with rounded corners. Icons of the two
+ * member apps are set into these rectangles.
  */
 public class AppPairIcon extends FrameLayout implements DraggableView {
+    /**
+     * Design specs -- the below ratios are in relation to the size of a standard app icon.
+     */
+    private static final float OUTER_PADDING_SCALE = 1 / 30f;
+    private static final float INNER_PADDING_SCALE = 1 / 24f;
+    private static final float MEMBER_ICON_SCALE = 11 / 30f;
+    private static final float CENTER_CHANNEL_SCALE = 1 / 30f;
+    private static final float BIG_RADIUS_SCALE = 1 / 5f;
+    private static final float SMALL_RADIUS_SCALE = 1 / 15f;
+
+    // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on
+    // each side.
+    float mOuterPadding;
+    // Inside of the icon, the two member apps are padded by this much.
+    float mInnerPadding;
+    // The two member apps have icons that are this big (in diameter).
+    float mMemberIconSize;
+    // The size of the center channel.
+    float mCenterChannelSize;
+    // The large outer radius of the background rectangles.
+    float mBigRadius;
+    // The small inner radius of the background rectangles.
+    float mSmallRadius;
+    // The app pairs icon appears differently in portrait and landscape.
+    boolean mIsLandscape;
 
     private ActivityContext mActivity;
+    // A view that holds the app pair's title.
     private BubbleTextView mAppPairName;
+    // The underlying ItemInfo that stores info about the app pair members, etc.
     private FolderInfo mInfo;
 
     public AppPairIcon(Context context, AttributeSet attrs) {
@@ -53,11 +86,11 @@
     }
 
     /**
-     * Builds an AppPairIcon to be added to the Launcher
+     * Builds an AppPairIcon to be added to the Launcher.
      */
     public static AppPairIcon inflateIcon(int resId, ActivityContext activity,
             @Nullable ViewGroup group, FolderInfo appPairInfo) {
-
+        DeviceProfile grid = activity.getDeviceProfile();
         LayoutInflater inflater = (group != null)
                 ? LayoutInflater.from(group.getContext())
                 : activity.getLayoutInflater();
@@ -67,26 +100,114 @@
         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;
 
+        // Set up app pair title
+        icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name);
+        icon.mAppPairName.setCompoundDrawablePadding(0);
+        FrameLayout.LayoutParams lp =
+                (FrameLayout.LayoutParams) icon.mAppPairName.getLayoutParams();
+        lp.topMargin = grid.iconSizePx + grid.iconDrawablePaddingPx;
+        icon.mAppPairName.setText(appPairInfo.title);
+
+        // Set up accessibility
+        icon.setContentDescription(icon.getAccessibilityTitle(
+                appPairInfo.contents.get(0).title, appPairInfo.contents.get(1).title));
         icon.setAccessibilityDelegate(activity.getAccessibilityDelegate());
 
         return icon;
     }
 
     @Override
+    protected void dispatchDraw(Canvas canvas) {
+        super.dispatchDraw(canvas);
+
+        // Calculate device-specific measurements
+        DeviceProfile grid = mActivity.getDeviceProfile();
+        int defaultIconSize = grid.iconSizePx;
+        mOuterPadding = OUTER_PADDING_SCALE * defaultIconSize;
+        mInnerPadding = INNER_PADDING_SCALE * defaultIconSize;
+        mMemberIconSize = MEMBER_ICON_SCALE * defaultIconSize;
+        mCenterChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize;
+        mBigRadius = BIG_RADIUS_SCALE * defaultIconSize;
+        mSmallRadius = SMALL_RADIUS_SCALE * defaultIconSize;
+        mIsLandscape = grid.isLandscape;
+
+        // Calculate drawable area position
+        float leftBound = (canvas.getWidth() / 2f) - (defaultIconSize / 2f);
+        float topBound = getPaddingTop();
+
+        // Prepare to draw app pair icon background
+        Drawable background = new AppPairIconBackground(getContext(), this);
+        background.setBounds(0, 0, defaultIconSize, defaultIconSize);
+
+        // Draw background
+        canvas.save();
+        canvas.translate(leftBound, topBound);
+        background.draw(canvas);
+        canvas.restore();
+
+        // Prepare to draw icons
+        WorkspaceItemInfo app1 = mInfo.contents.get(0);
+        WorkspaceItemInfo app2 = mInfo.contents.get(1);
+        Drawable app1Icon = app1.newIcon(getContext());
+        Drawable app2Icon = app2.newIcon(getContext());
+        app1Icon.setBounds(0, 0, defaultIconSize, defaultIconSize);
+        app2Icon.setBounds(0, 0, defaultIconSize, defaultIconSize);
+
+        // Draw first icon
+        canvas.save();
+        canvas.translate(leftBound, topBound);
+        // The app icons are placed differently depending on device orientation.
+        if (mIsLandscape) {
+            canvas.translate(
+                    (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding
+                            - mMemberIconSize,
+                    (defaultIconSize / 2f) - (mMemberIconSize / 2f)
+            );
+        } else {
+            canvas.translate(
+                    (defaultIconSize / 2f) - (mMemberIconSize / 2f),
+                    (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding
+                            - mMemberIconSize
+            );
+
+        }
+        canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE);
+        app1Icon.draw(canvas);
+        canvas.restore();
+
+        // Draw second icon
+        canvas.save();
+        canvas.translate(leftBound, topBound);
+        // The app icons are placed differently depending on device orientation.
+        if (mIsLandscape) {
+            canvas.translate(
+                    (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding,
+                    (defaultIconSize / 2f) - (mMemberIconSize / 2f)
+            );
+        } else {
+            canvas.translate(
+                    (defaultIconSize / 2f) - (mMemberIconSize / 2f),
+                    (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding
+            );
+        }
+        canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE);
+        app2Icon.draw(canvas);
+        canvas.restore();
+    }
+
+    /**
+     * Returns a formatted accessibility title for app pairs.
+     */
+    public String getAccessibilityTitle(CharSequence app1, CharSequence app2) {
+        return getContext().getString(R.string.app_pair_name_format, app1, app2);
+    }
+
+    @Override
     public int getViewType() {
         return DRAGGABLE_ICON;
     }
diff --git a/src/com/android/launcher3/apppairs/AppPairIconBackground.java b/src/com/android/launcher3/apppairs/AppPairIconBackground.java
new file mode 100644
index 0000000..735c82f
--- /dev/null
+++ b/src/com/android/launcher3/apppairs/AppPairIconBackground.java
@@ -0,0 +1,167 @@
+/*
+ * 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.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+
+import com.android.launcher3.R;
+
+/**
+ * A Drawable for the background behind the twin app icons (looks like two rectangles).
+ */
+class AppPairIconBackground extends Drawable {
+    // The icon that we will draw this background on.
+    private final AppPairIcon icon;
+    private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+    /**
+     * Null values to use with
+     * {@link Canvas#drawDoubleRoundRect(RectF, float[], RectF, float[], Paint)}, since there
+     * doesn't seem to be any other API for drawing rectangles with 4 different corner radii.
+     */
+    private static final RectF EMPTY_RECT = new RectF();
+    private static final float[] ARRAY_OF_ZEROES = new float[8];
+
+    AppPairIconBackground(Context context, AppPairIcon appPairIcon) {
+        icon = appPairIcon;
+        // Set up background paint color
+        TypedArray ta = context.getTheme().obtainStyledAttributes(R.styleable.FolderIconPreview);
+        mBackgroundPaint.setStyle(Paint.Style.FILL);
+        mBackgroundPaint.setColor(
+                ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0));
+        ta.recycle();
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        if (icon.mIsLandscape) {
+            drawLeftRightSplit(canvas);
+        } else {
+            drawTopBottomSplit(canvas);
+        }
+    }
+
+    /**
+     * When device is in landscape, we draw the rectangles with a left-right split.
+     */
+    private void drawLeftRightSplit(Canvas canvas) {
+        // Get the bounds where we will draw the background image
+        int width = getBounds().width();
+        int height = getBounds().height();
+
+        // The left half of the background image, excluding center channel
+        RectF leftSide = new RectF(
+                icon.mOuterPadding,
+                icon.mOuterPadding,
+                (width / 2f) - (icon.mCenterChannelSize / 2f),
+                height - icon.mOuterPadding
+        );
+        // The right half of the background image, excluding center channel
+        RectF rightSide = new RectF(
+                (width / 2f) + (icon.mCenterChannelSize / 2f),
+                icon.mOuterPadding,
+                width - icon.mOuterPadding,
+                height - icon.mOuterPadding
+        );
+
+        drawCustomRoundedRect(canvas, leftSide, new float[]{
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mBigRadius, icon.mBigRadius});
+        drawCustomRoundedRect(canvas, rightSide, new float[]{
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mSmallRadius, icon.mSmallRadius});
+    }
+
+    /**
+     * When device is in portrait, we draw the rectangles with a top-bottom split.
+     */
+    private void drawTopBottomSplit(Canvas canvas) {
+        // Get the bounds where we will draw the background image
+        int width = getBounds().width();
+        int height = getBounds().height();
+
+        // The top half of the background image, excluding center channel
+        RectF topSide = new RectF(
+                icon.mOuterPadding,
+                icon.mOuterPadding,
+                width - icon.mOuterPadding,
+                (height / 2f) - (icon.mCenterChannelSize / 2f)
+        );
+        // The bottom half of the background image, excluding center channel
+        RectF bottomSide = new RectF(
+                icon.mOuterPadding,
+                (height / 2f) + (icon.mCenterChannelSize / 2f),
+                width - icon.mOuterPadding,
+                height - icon.mOuterPadding
+        );
+
+        drawCustomRoundedRect(canvas, topSide, new float[]{
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mSmallRadius, icon.mSmallRadius});
+        drawCustomRoundedRect(canvas, bottomSide, new float[]{
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mSmallRadius, icon.mSmallRadius,
+                icon.mBigRadius, icon.mBigRadius,
+                icon.mBigRadius, icon.mBigRadius});
+    }
+
+    /**
+     * Draws a rectangle with custom rounded corners.
+     * @param c The Canvas to draw on.
+     * @param rect The bounds of the rectangle.
+     * @param radii An array of 8 radii for the corners: top left x, top left y, top right x, top
+     *              right y, bottom right x, and so on.
+     */
+    private void drawCustomRoundedRect(Canvas c, RectF rect, float[] radii) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            // Canvas.drawDoubleRoundRect is supported from Q onward
+            c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, mBackgroundPaint);
+        } else {
+            // Fallback rectangle with uniform rounded corners
+            c.drawRoundRect(rect, icon.mBigRadius, icon.mBigRadius, mBackgroundPaint);
+        }
+    }
+
+    @Override
+    public int getOpacity() {
+        return PixelFormat.OPAQUE;
+    }
+
+    @Override
+    public void setAlpha(int i) {
+        // Required by Drawable but not used.
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter colorFilter) {
+        // Required by Drawable but not used.
+    }
+}