Refactor how app pair icons draw
This changes (and cleans up) the way app pair icons are composed. Previously, the background and 2 icons were drawn individually and separately onto the canvas. Now, they are composed into a combined drawable first. This also allows the full icon drawable to be requested by external functions (which will be needed for display app pairs in folder previews).
Bug: 315731527
Flag: ACONFIG com.android.wm.shell.enable_app_pairs TRUNKFOOD
Test: Visually confirmed that app pairs loooks the same in all scenarios: rotation, disabled, themed, taskbar, pinned taskbar. Screenshot test to follow.
Change-Id: I7242e0c525ef578a54a06fb9137fcfc42c6f0e86
(cherry picked from commit b37faec287)
Merged-In: I7242e0c525ef578a54a06fb9137fcfc42c6f0e86
This commit is contained in:
@@ -354,7 +354,8 @@ public class TaskbarView extends FrameLayout implements FolderIcon.FolderIconPar
|
||||
break;
|
||||
case ITEM_TYPE_APP_PAIR:
|
||||
hotseatView = AppPairIcon.inflateIcon(
|
||||
expectedLayoutResId, mActivityContext, this, folderInfo);
|
||||
expectedLayoutResId, mActivityContext, this, folderInfo,
|
||||
BubbleTextView.DISPLAY_TASKBAR);
|
||||
((AppPairIcon) hotseatView).setTextVisible(false);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -58,7 +58,6 @@ import androidx.annotation.UiThread;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.android.launcher3.accessibility.BaseAccessibilityDelegate;
|
||||
import com.android.launcher3.config.FeatureFlags;
|
||||
import com.android.launcher3.dot.DotInfo;
|
||||
import com.android.launcher3.dragndrop.DragOptions.PreDragCondition;
|
||||
import com.android.launcher3.dragndrop.DraggableView;
|
||||
@@ -96,10 +95,10 @@ import java.util.Locale;
|
||||
public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver,
|
||||
IconLabelDotView, DraggableView, Reorderable {
|
||||
|
||||
private static final int DISPLAY_WORKSPACE = 0;
|
||||
public static final int DISPLAY_WORKSPACE = 0;
|
||||
public static final int DISPLAY_ALL_APPS = 1;
|
||||
private static final int DISPLAY_FOLDER = 2;
|
||||
protected static final int DISPLAY_TASKBAR = 5;
|
||||
public static final int DISPLAY_FOLDER = 2;
|
||||
public static final int DISPLAY_TASKBAR = 5;
|
||||
public static final int DISPLAY_SEARCH_RESULT = 6;
|
||||
public static final int DISPLAY_SEARCH_RESULT_SMALL = 7;
|
||||
public static final int DISPLAY_PREDICTION_ROW = 8;
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
package com.android.launcher3.apppairs;
|
||||
|
||||
import static com.android.launcher3.BubbleTextView.DISPLAY_FOLDER;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
@@ -38,7 +39,6 @@ import com.android.launcher3.model.data.WorkspaceItemInfo;
|
||||
import com.android.launcher3.util.MultiTranslateDelegate;
|
||||
import com.android.launcher3.views.ActivityContext;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@@ -62,6 +62,9 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
private BubbleTextView mAppPairName;
|
||||
// The underlying ItemInfo that stores info about the app pair members, etc.
|
||||
private FolderInfo mInfo;
|
||||
// The containing element that holds this icon: workspace, taskbar, folder, etc. Affects certain
|
||||
// aspects of how the icon is drawn.
|
||||
private int mContainer;
|
||||
|
||||
// Required for Reorderable -- handles translation and bouncing movements
|
||||
private final MultiTranslateDelegate mTranslateDelegate = new MultiTranslateDelegate(this);
|
||||
@@ -79,7 +82,7 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
* Builds an AppPairIcon to be added to the Launcher.
|
||||
*/
|
||||
public static AppPairIcon inflateIcon(int resId, ActivityContext activity,
|
||||
@Nullable ViewGroup group, FolderInfo appPairInfo) {
|
||||
@Nullable ViewGroup group, FolderInfo appPairInfo, int container) {
|
||||
DeviceProfile grid = activity.getDeviceProfile();
|
||||
LayoutInflater inflater = (group != null)
|
||||
? LayoutInflater.from(group.getContext())
|
||||
@@ -87,30 +90,32 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
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));
|
||||
appPairInfo.contents.sort(Comparator.comparingInt(a -> a.rank));
|
||||
|
||||
icon.setClipToPadding(false);
|
||||
icon.setTag(appPairInfo);
|
||||
icon.setOnClickListener(activity.getItemOnClickListener());
|
||||
icon.mInfo = appPairInfo;
|
||||
icon.mContainer = container;
|
||||
|
||||
if (icon.mInfo.contents.size() != 2) {
|
||||
Log.wtf(TAG, "AppPair contents not 2, size: " + icon.mInfo.contents.size());
|
||||
return icon;
|
||||
}
|
||||
|
||||
icon.checkScreenSize();
|
||||
icon.checkDisabledState();
|
||||
|
||||
// Set up icon drawable area
|
||||
icon.mIconGraphic = icon.findViewById(R.id.app_pair_icon_graphic);
|
||||
icon.mIconGraphic.init(activity, icon);
|
||||
icon.mIconGraphic.init(icon, container);
|
||||
|
||||
// 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;
|
||||
// Shift the title text down to leave room for the icon graphic. Since the icon graphic is
|
||||
// a separate element (and not set as a CompoundDrawable on the BubbleTextView), we need to
|
||||
// shift the text down manually.
|
||||
lp.topMargin = container == DISPLAY_FOLDER
|
||||
? grid.folderChildIconSizePx + grid.folderChildDrawablePaddingPx
|
||||
: grid.iconSizePx + grid.iconDrawablePaddingPx;
|
||||
// For some reason, app icons have setIncludeFontPadding(false) inside folders, so we set it
|
||||
// here to match that.
|
||||
icon.mAppPairName.setIncludeFontPadding(container != DISPLAY_FOLDER);
|
||||
icon.mAppPairName.setText(appPairInfo.title);
|
||||
|
||||
// Set up accessibility
|
||||
@@ -174,7 +179,11 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
return mInfo;
|
||||
}
|
||||
|
||||
public View getIconDrawableArea() {
|
||||
public BubbleTextView getTitleTextView() {
|
||||
return mAppPairName;
|
||||
}
|
||||
|
||||
public AppPairIconGraphic getIconDrawableArea() {
|
||||
return mIconGraphic;
|
||||
}
|
||||
|
||||
@@ -194,12 +203,14 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
* {@link AppPairIconGraphic#dispatchDraw(Canvas)} or clicked on
|
||||
* {@link com.android.launcher3.touch.ItemClickHandler#onClickAppPairIcon(View)}
|
||||
*/
|
||||
public void checkScreenSize() {
|
||||
public void checkDisabledState() {
|
||||
DeviceProfile dp = ActivityContext.lookupContext(getContext()).getDeviceProfile();
|
||||
// If user is on a small screen, we can't launch if either of the apps is non-resizeable
|
||||
mIsLaunchableAtScreenSize =
|
||||
dp.isTablet || getInfo().contents.stream().noneMatch(
|
||||
wii -> wii.hasStatusFlag(WorkspaceItemInfo.FLAG_NON_RESIZEABLE));
|
||||
// Invalidate to update icons
|
||||
mIconGraphic.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,8 +220,26 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab
|
||||
// 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().contents.stream().anyMatch(itemCheck)) {
|
||||
checkScreenSize();
|
||||
mIconGraphic.invalidate();
|
||||
checkDisabledState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inside folders, icons are vertically centered in their rows. See
|
||||
* {@link BubbleTextView#onMeasure(int, int)} for comparison.
|
||||
*/
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
if (mContainer == DISPLAY_FOLDER) {
|
||||
int height = MeasureSpec.getSize(heightMeasureSpec);
|
||||
ActivityContext activity = ActivityContext.lookupContext(getContext());
|
||||
Paint.FontMetrics fm = mAppPairName.getPaint().getFontMetrics();
|
||||
int cellHeightPx = activity.getDeviceProfile().folderChildIconSizePx
|
||||
+ activity.getDeviceProfile().folderChildDrawablePaddingPx
|
||||
+ (int) Math.ceil(fm.bottom - fm.top);
|
||||
setPadding(getPaddingLeft(), (height - cellHeightPx) / 2, getPaddingRight(),
|
||||
getPaddingBottom());
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +1 @@
|
||||
/*
|
||||
* 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 underlying view that we are drawing this background on.
|
||||
private final AppPairIconGraphic 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, AppPairIconGraphic iconGraphic) {
|
||||
icon = iconGraphic;
|
||||
// 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.isLeftRightSplit()) {
|
||||
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(
|
||||
0,
|
||||
0,
|
||||
(width / 2f) - (icon.getCenterChannelSize() / 2f),
|
||||
height
|
||||
);
|
||||
// The right half of the background image, excluding center channel
|
||||
RectF rightSide = new RectF(
|
||||
(width / 2f) + (icon.getCenterChannelSize() / 2f),
|
||||
0,
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
drawCustomRoundedRect(canvas, leftSide, new float[]{
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius()});
|
||||
drawCustomRoundedRect(canvas, rightSide, new float[]{
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius()});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
(height / 2f) - (icon.getCenterChannelSize() / 2f)
|
||||
);
|
||||
// The bottom half of the background image, excluding center channel
|
||||
RectF bottomSide = new RectF(
|
||||
0,
|
||||
(height / 2f) + (icon.getCenterChannelSize() / 2f),
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
drawCustomRoundedRect(canvas, topSide, new float[]{
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius()});
|
||||
drawCustomRoundedRect(canvas, bottomSide, new float[]{
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getSmallRadius(), icon.getSmallRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius(),
|
||||
icon.getBigRadius(), icon.getBigRadius()});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.getBigRadius(), icon.getBigRadius(), mBackgroundPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity() {
|
||||
return PixelFormat.OPAQUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int i) {
|
||||
mBackgroundPaint.setAlpha(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(ColorFilter colorFilter) {
|
||||
// Required by Drawable but not used.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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.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 androidx.annotation.NonNull;
|
||||
|
||||
import com.android.launcher3.icons.FastBitmapDrawable;
|
||||
|
||||
/**
|
||||
* A composed Drawable consisting of the two app pair icons and the background behind them (looks
|
||||
* like two rectangles).
|
||||
*/
|
||||
class AppPairIconDrawable extends Drawable {
|
||||
private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final AppPairIconDrawingParams mP;
|
||||
private final FastBitmapDrawable mIcon1;
|
||||
private final FastBitmapDrawable mIcon2;
|
||||
|
||||
/**
|
||||
* 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];
|
||||
|
||||
AppPairIconDrawable(
|
||||
AppPairIconDrawingParams p, FastBitmapDrawable icon1, FastBitmapDrawable icon2) {
|
||||
mP = p;
|
||||
mBackgroundPaint.setStyle(Paint.Style.FILL);
|
||||
mBackgroundPaint.setColor(p.getBgColor());
|
||||
mIcon1 = icon1;
|
||||
mIcon2 = icon2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas) {
|
||||
if (mP.isLeftRightSplit()) {
|
||||
drawLeftRightSplit(canvas);
|
||||
} else {
|
||||
drawTopBottomSplit(canvas);
|
||||
}
|
||||
|
||||
canvas.translate(
|
||||
mP.getStandardIconPadding() + mP.getOuterPadding(),
|
||||
mP.getStandardIconPadding() + mP.getOuterPadding()
|
||||
);
|
||||
|
||||
// Draw first icon.
|
||||
canvas.save();
|
||||
// The app icons are placed differently depending on device orientation.
|
||||
if (mP.isLeftRightSplit()) {
|
||||
canvas.translate(
|
||||
mP.getInnerPadding(),
|
||||
mP.getBackgroundSize() / 2f - mP.getMemberIconSize() / 2f
|
||||
);
|
||||
} else {
|
||||
canvas.translate(
|
||||
mP.getBackgroundSize() / 2f - mP.getMemberIconSize() / 2f,
|
||||
mP.getInnerPadding()
|
||||
);
|
||||
}
|
||||
|
||||
mIcon1.draw(canvas);
|
||||
canvas.restore();
|
||||
|
||||
// Draw second icon.
|
||||
canvas.save();
|
||||
// The app icons are placed differently depending on device orientation.
|
||||
if (mP.isLeftRightSplit()) {
|
||||
canvas.translate(
|
||||
mP.getBackgroundSize() - (mP.getInnerPadding() + mP.getMemberIconSize()),
|
||||
mP.getBackgroundSize() / 2f - mP.getMemberIconSize() / 2f
|
||||
);
|
||||
} else {
|
||||
canvas.translate(
|
||||
mP.getBackgroundSize() / 2f - mP.getMemberIconSize() / 2f,
|
||||
mP.getBackgroundSize() - (mP.getInnerPadding() + mP.getMemberIconSize())
|
||||
);
|
||||
}
|
||||
|
||||
mIcon2.draw(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 = mP.getIconSize();
|
||||
int height = mP.getIconSize();
|
||||
|
||||
// The left half of the background image, excluding center channel
|
||||
RectF leftSide = new RectF(
|
||||
mP.getStandardIconPadding() + mP.getOuterPadding(),
|
||||
mP.getStandardIconPadding() + mP.getOuterPadding(),
|
||||
(width / 2f) - (mP.getCenterChannelSize() / 2f),
|
||||
height - (mP.getStandardIconPadding() + mP.getOuterPadding())
|
||||
);
|
||||
// The right half of the background image, excluding center channel
|
||||
RectF rightSide = new RectF(
|
||||
(width / 2f) + (mP.getCenterChannelSize() / 2f),
|
||||
(mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
width - (mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
height - (mP.getStandardIconPadding() + mP.getOuterPadding())
|
||||
);
|
||||
|
||||
drawCustomRoundedRect(canvas, leftSide, new float[]{
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius()});
|
||||
drawCustomRoundedRect(canvas, rightSide, new float[]{
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius()});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = mP.getIconSize();
|
||||
int height = mP.getIconSize();
|
||||
|
||||
// The top half of the background image, excluding center channel
|
||||
RectF topSide = new RectF(
|
||||
(mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
(mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
width - (mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
(height / 2f) - (mP.getCenterChannelSize() / 2f)
|
||||
);
|
||||
// The bottom half of the background image, excluding center channel
|
||||
RectF bottomSide = new RectF(
|
||||
(mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
(height / 2f) + (mP.getCenterChannelSize() / 2f),
|
||||
width - (mP.getStandardIconPadding() + mP.getOuterPadding()),
|
||||
height - (mP.getStandardIconPadding() + mP.getOuterPadding())
|
||||
);
|
||||
|
||||
drawCustomRoundedRect(canvas, topSide, new float[]{
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius()});
|
||||
drawCustomRoundedRect(canvas, bottomSide, new float[]{
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getSmallRadius(), mP.getSmallRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius(),
|
||||
mP.getBigRadius(), mP.getBigRadius()});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, mP.getBigRadius(), mP.getBigRadius(), mBackgroundPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity() {
|
||||
return PixelFormat.OPAQUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int i) {
|
||||
mBackgroundPaint.setAlpha(i);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(ColorFilter colorFilter) {
|
||||
mBackgroundPaint.setColorFilter(colorFilter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.android.launcher3.apppairs
|
||||
|
||||
import android.content.Context
|
||||
import com.android.launcher3.BubbleTextView.DISPLAY_FOLDER
|
||||
import com.android.launcher3.DeviceProfile
|
||||
import com.android.launcher3.R
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
|
||||
class AppPairIconDrawingParams(val context: Context, container: Int) {
|
||||
companion object {
|
||||
// Design specs -- the below ratios are in relation to the size of a standard app icon.
|
||||
// Note: The standard app icon has two sizes. One is the full size of the drawable (returned
|
||||
// by dp.iconSizePx), and one is the visual size of the icon on-screen (11/12 of that).
|
||||
// Hence the calculations below.
|
||||
const val STANDARD_ICON_PADDING = 1 / 24f
|
||||
const val STANDARD_ICON_SHRINK = 1 - STANDARD_ICON_PADDING * 2
|
||||
// App pairs are slightly smaller than the *visual* size of a standard icon, so all ratios
|
||||
// are calculated with that in mind.
|
||||
const val OUTER_PADDING_SCALE = 1 / 30f * STANDARD_ICON_SHRINK
|
||||
const val INNER_PADDING_SCALE = 1 / 24f * STANDARD_ICON_SHRINK
|
||||
const val CENTER_CHANNEL_SCALE = 1 / 30f * STANDARD_ICON_SHRINK
|
||||
const val BIG_RADIUS_SCALE = 1 / 5f * STANDARD_ICON_SHRINK
|
||||
const val SMALL_RADIUS_SCALE = 1 / 15f * STANDARD_ICON_SHRINK
|
||||
const val MEMBER_ICON_SCALE = 11 / 30f * STANDARD_ICON_SHRINK
|
||||
}
|
||||
|
||||
// The size at which this graphic will be drawn.
|
||||
val iconSize: Int
|
||||
// Standard app icons are padded by this amount on each side.
|
||||
val standardIconPadding: Float
|
||||
// App pair icons are slightly smaller than regular icons, so we pad the icon by this much on
|
||||
// each side.
|
||||
val outerPadding: Float
|
||||
// The colored background (two rectangles in a square area) is this big.
|
||||
val backgroundSize: Float
|
||||
// The size of the channel between the two halves of the app pair icon.
|
||||
val centerChannelSize: Float
|
||||
// The corner radius of the outside corners.
|
||||
val bigRadius: Float
|
||||
// The corner radius of the inside corners, touching the center channel.
|
||||
val smallRadius: Float
|
||||
// Inside of the icon, the two member apps are padded by this much.
|
||||
val innerPadding: Float
|
||||
// The two member apps have icons that are this big (in diameter).
|
||||
val memberIconSize: Float
|
||||
// The app pair icon appears differently in portrait and landscape.
|
||||
var isLeftRightSplit: Boolean = true
|
||||
// The background paint color (based on container).
|
||||
val bgColor: Int
|
||||
|
||||
init {
|
||||
val activity: ActivityContext = ActivityContext.lookupContext(context)
|
||||
val dp = activity.deviceProfile
|
||||
iconSize = if (container == DISPLAY_FOLDER) dp.folderChildIconSizePx else dp.iconSizePx
|
||||
standardIconPadding = iconSize * STANDARD_ICON_PADDING
|
||||
outerPadding = iconSize * OUTER_PADDING_SCALE
|
||||
backgroundSize = iconSize * STANDARD_ICON_SHRINK - (outerPadding * 2)
|
||||
centerChannelSize = iconSize * CENTER_CHANNEL_SCALE
|
||||
bigRadius = iconSize * BIG_RADIUS_SCALE
|
||||
smallRadius = iconSize * SMALL_RADIUS_SCALE
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks the device orientation and updates isLeftRightSplit accordingly. */
|
||||
fun updateOrientation(dp: DeviceProfile) {
|
||||
isLeftRightSplit = dp.isLeftRightSplit
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,13 @@ import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import com.android.launcher3.DeviceProfile
|
||||
import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener
|
||||
import com.android.launcher3.icons.BitmapInfo
|
||||
import com.android.launcher3.icons.PlaceHolderIconDrawable
|
||||
import com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter
|
||||
import com.android.launcher3.model.data.FolderInfo
|
||||
import com.android.launcher3.model.data.WorkspaceItemInfo
|
||||
import com.android.launcher3.util.Themes
|
||||
import com.android.launcher3.views.ActivityContext
|
||||
@@ -41,168 +41,101 @@ class AppPairIconGraphic @JvmOverloads constructor(context: Context, attrs: Attr
|
||||
private val TAG = "AppPairIconGraphic"
|
||||
|
||||
companion object {
|
||||
// Design specs -- the below ratios are in relation to the size of a standard app icon.
|
||||
private const val OUTER_PADDING_SCALE = 1 / 30f
|
||||
private const val INNER_PADDING_SCALE = 1 / 24f
|
||||
private const val MEMBER_ICON_SCALE = 11 / 30f
|
||||
private const val CENTER_CHANNEL_SCALE = 1 / 30f
|
||||
private const val BIG_RADIUS_SCALE = 1 / 5f
|
||||
private const val SMALL_RADIUS_SCALE = 1 / 15f
|
||||
// Disabled alpha is 38%, or 97/255
|
||||
private const val DISABLED_ALPHA = 97
|
||||
private const val ENABLED_ALPHA = 255
|
||||
/** Composes a drawable for this icon, consisting of a background and 2 app icons. */
|
||||
@JvmStatic
|
||||
fun composeDrawable(appPairInfo: FolderInfo, p: AppPairIconDrawingParams): Drawable {
|
||||
// Generate new icons, using themed flag if needed.
|
||||
val flags = if (Themes.isThemedIconEnabled(p.context)) BitmapInfo.FLAG_THEMED else 0
|
||||
val appIcon1 = appPairInfo.contents[0].newIcon(p.context, flags)
|
||||
val appIcon2 = appPairInfo.contents[1].newIcon(p.context, flags)
|
||||
appIcon1.setBounds(0, 0, p.memberIconSize.toInt(), p.memberIconSize.toInt())
|
||||
appIcon2.setBounds(0, 0, p.memberIconSize.toInt(), p.memberIconSize.toInt())
|
||||
|
||||
// Check disabled status.
|
||||
val activity: ActivityContext = ActivityContext.lookupContext(p.context)
|
||||
val isLaunchableAtScreenSize =
|
||||
activity.deviceProfile.isTablet ||
|
||||
appPairInfo.contents.stream().noneMatch { wii: WorkspaceItemInfo ->
|
||||
wii.hasStatusFlag(WorkspaceItemInfo.FLAG_NON_RESIZEABLE)
|
||||
}
|
||||
val shouldDrawAsDisabled = appPairInfo.isDisabled || !isLaunchableAtScreenSize
|
||||
|
||||
// Set disabled status on icons.
|
||||
appIcon1.setIsDisabled(shouldDrawAsDisabled)
|
||||
appIcon2.setIsDisabled(shouldDrawAsDisabled)
|
||||
|
||||
// Create icon drawable.
|
||||
val fullIconDrawable = AppPairIconDrawable(p, appIcon1, appIcon2)
|
||||
fullIconDrawable.setBounds(0, 0, p.iconSize, p.iconSize)
|
||||
|
||||
// Set disabled color filter on background paint.
|
||||
fullIconDrawable.colorFilter =
|
||||
if (shouldDrawAsDisabled) getDisabledColorFilter() else null
|
||||
|
||||
return fullIconDrawable
|
||||
}
|
||||
}
|
||||
|
||||
// App pair icons are slightly smaller than regular icons, so we pad the icon by this much on
|
||||
// each side.
|
||||
private var outerPadding = 0f
|
||||
// Inside of the icon, the two member apps are padded by this much.
|
||||
private var innerPadding = 0f
|
||||
// The colored background (two rectangles in a square area) is this big.
|
||||
private var backgroundSize = 0f
|
||||
// The two member apps have icons that are this big (in diameter).
|
||||
private var memberIconSize = 0f
|
||||
// The size of the center channel.
|
||||
var centerChannelSize = 0f
|
||||
// The large outer radius of the background rectangles.
|
||||
var bigRadius = 0f
|
||||
// The small inner radius of the background rectangles.
|
||||
var smallRadius = 0f
|
||||
// The app pairs icon appears differently in portrait and landscape.
|
||||
var isLeftRightSplit = false
|
||||
|
||||
private lateinit var activityContext: ActivityContext
|
||||
private lateinit var parentIcon: AppPairIcon
|
||||
private lateinit var appPairBackground: Drawable
|
||||
private var appIcon1: Drawable? = null
|
||||
private var appIcon2: Drawable? = null
|
||||
private lateinit var drawParams: AppPairIconDrawingParams
|
||||
private lateinit var drawable: Drawable
|
||||
|
||||
fun init(activity: ActivityContext, icon: AppPairIcon) {
|
||||
activityContext = activity
|
||||
|
||||
// Calculate device-specific measurements
|
||||
val defaultIconSize = activity.deviceProfile.iconSizePx
|
||||
outerPadding = OUTER_PADDING_SCALE * defaultIconSize
|
||||
innerPadding = INNER_PADDING_SCALE * defaultIconSize
|
||||
backgroundSize = defaultIconSize - outerPadding * 2
|
||||
memberIconSize = MEMBER_ICON_SCALE * defaultIconSize
|
||||
centerChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize
|
||||
bigRadius = BIG_RADIUS_SCALE * defaultIconSize
|
||||
smallRadius = SMALL_RADIUS_SCALE * defaultIconSize
|
||||
fun init(icon: AppPairIcon, container: Int) {
|
||||
parentIcon = icon
|
||||
updateOrientation()
|
||||
|
||||
appPairBackground = AppPairIconBackground(context, this)
|
||||
appPairBackground.setBounds(0, 0, backgroundSize.toInt(), backgroundSize.toInt())
|
||||
applyIcons(parentIcon.info.contents)
|
||||
drawParams = AppPairIconDrawingParams(context, container)
|
||||
drawable = composeDrawable(icon.info, drawParams)
|
||||
|
||||
// Center the drawable area in the larger icon canvas
|
||||
val lp: LayoutParams = layoutParams as LayoutParams
|
||||
lp.gravity = Gravity.CENTER_HORIZONTAL
|
||||
lp.topMargin = outerPadding.toInt()
|
||||
lp.height = backgroundSize.toInt()
|
||||
lp.width = backgroundSize.toInt()
|
||||
lp.height = drawParams.iconSize
|
||||
lp.width = drawParams.iconSize
|
||||
layoutParams = lp
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
activityContext.addOnDeviceProfileChangeListener(this)
|
||||
getActivityContext().addOnDeviceProfileChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
activityContext.removeOnDeviceProfileChangeListener(this)
|
||||
getActivityContext().removeOnDeviceProfileChangeListener(this)
|
||||
}
|
||||
|
||||
/** Checks the device orientation and updates isLeftRightSplit accordingly. */
|
||||
private fun updateOrientation() {
|
||||
val activity: ActivityContext = ActivityContext.lookupContext(context)
|
||||
isLeftRightSplit = activity.deviceProfile.isLeftRightSplit
|
||||
private fun getActivityContext(): ActivityContext {
|
||||
return ActivityContext.lookupContext(context)
|
||||
}
|
||||
|
||||
/** When device profile changes, update orientation */
|
||||
override fun onDeviceProfileChanged(dp: DeviceProfile?) {
|
||||
updateOrientation()
|
||||
override fun onDeviceProfileChanged(dp: DeviceProfile) {
|
||||
drawParams.updateOrientation(dp)
|
||||
redraw()
|
||||
}
|
||||
|
||||
/** Updates the icon drawable and redraws it */
|
||||
fun redraw() {
|
||||
drawable = composeDrawable(parentIcon.info, drawParams)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/** Sets up app pair member icons for drawing. */
|
||||
private fun applyIcons(contents: ArrayList<WorkspaceItemInfo>) {
|
||||
// App pair should always contain 2 members; if not 2, return to avoid a crash loop
|
||||
if (contents.size != 2) {
|
||||
Log.wtf(TAG, "AppPair contents not 2, size: " + contents.size, Throwable())
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new icons, using themed flag if needed
|
||||
val flags = if (Themes.isThemedIconEnabled(context)) BitmapInfo.FLAG_THEMED else 0
|
||||
val newIcon1 = parentIcon.info.contents[0].newIcon(context, flags)
|
||||
val newIcon2 = parentIcon.info.contents[1].newIcon(context, flags)
|
||||
|
||||
// If app icons did not draw fully last time, animate to full icon
|
||||
(appIcon1 as? PlaceHolderIconDrawable)?.animateIconUpdate(newIcon1)
|
||||
(appIcon2 as? PlaceHolderIconDrawable)?.animateIconUpdate(newIcon2)
|
||||
|
||||
appIcon1 = newIcon1
|
||||
appIcon2 = newIcon2
|
||||
appIcon1?.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt())
|
||||
appIcon2?.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt())
|
||||
}
|
||||
|
||||
/** Gets this icon graphic's bounds, with respect to the parent icon's coordinate system. */
|
||||
/**
|
||||
* Gets this icon graphic's visual bounds, with respect to the parent icon's coordinate system.
|
||||
*/
|
||||
fun getIconBounds(outBounds: Rect) {
|
||||
outBounds.set(0, 0, backgroundSize.toInt(), backgroundSize.toInt())
|
||||
outBounds.set(0, 0, drawParams.backgroundSize.toInt(), drawParams.backgroundSize.toInt())
|
||||
|
||||
outBounds.offset(
|
||||
// x-coordinate in parent's coordinate system
|
||||
((parentIcon.width - backgroundSize) / 2).toInt(),
|
||||
((parentIcon.width - drawParams.backgroundSize) / 2).toInt(),
|
||||
// y-coordinate in parent's coordinate system
|
||||
parentIcon.paddingTop + outerPadding.toInt()
|
||||
(parentIcon.paddingTop + drawParams.standardIconPadding + drawParams.outerPadding)
|
||||
.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas) {
|
||||
super.dispatchDraw(canvas)
|
||||
|
||||
val drawAlpha =
|
||||
if (!parentIcon.isLaunchableAtScreenSize || parentIcon.info.isDisabled) DISABLED_ALPHA
|
||||
else ENABLED_ALPHA
|
||||
|
||||
// Draw background
|
||||
appPairBackground.alpha = drawAlpha
|
||||
appPairBackground.draw(canvas)
|
||||
|
||||
// Make sure icons are loaded and fresh
|
||||
applyIcons(parentIcon.info.contents)
|
||||
|
||||
// Draw first icon
|
||||
canvas.save()
|
||||
// The app icons are placed differently depending on device orientation.
|
||||
if (isLeftRightSplit) {
|
||||
canvas.translate(innerPadding, height / 2f - memberIconSize / 2f)
|
||||
} else {
|
||||
canvas.translate(width / 2f - memberIconSize / 2f, innerPadding)
|
||||
}
|
||||
appIcon1?.alpha = drawAlpha
|
||||
appIcon1?.draw(canvas)
|
||||
canvas.restore()
|
||||
|
||||
// Draw second icon
|
||||
canvas.save()
|
||||
// The app icons are placed differently depending on device orientation.
|
||||
if (isLeftRightSplit) {
|
||||
canvas.translate(
|
||||
width - (innerPadding + memberIconSize),
|
||||
height / 2f - memberIconSize / 2f
|
||||
)
|
||||
} else {
|
||||
canvas.translate(
|
||||
width / 2f - memberIconSize / 2f,
|
||||
height - (innerPadding + memberIconSize)
|
||||
)
|
||||
}
|
||||
appIcon2?.alpha = drawAlpha
|
||||
appIcon2?.draw(canvas)
|
||||
canvas.restore()
|
||||
drawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import static android.view.View.MeasureSpec.EXACTLY;
|
||||
import static android.view.View.MeasureSpec.makeMeasureSpec;
|
||||
import static android.view.View.VISIBLE;
|
||||
|
||||
import static com.android.launcher3.BubbleTextView.DISPLAY_TASKBAR;
|
||||
import static com.android.launcher3.BubbleTextView.DISPLAY_WORKSPACE;
|
||||
import static com.android.launcher3.DeviceProfile.DEFAULT_SCALE;
|
||||
import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
|
||||
import static com.android.launcher3.config.FeatureFlags.shouldShowFirstPageWidget;
|
||||
@@ -388,12 +390,14 @@ public class LauncherPreviewRenderer extends ContextWrapper
|
||||
}
|
||||
|
||||
private void inflateAndAddCollectionIcon(FolderInfo info) {
|
||||
CellLayout screen = info.container == Favorites.CONTAINER_DESKTOP
|
||||
boolean isOnDesktop = info.container == Favorites.CONTAINER_DESKTOP;
|
||||
CellLayout screen = isOnDesktop
|
||||
? mWorkspaceScreens.get(info.screenId)
|
||||
: mHotseat;
|
||||
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);
|
||||
: AppPairIcon.inflateIcon(R.layout.app_pair_icon, this, screen, info,
|
||||
isOnDesktop ? DISPLAY_WORKSPACE : DISPLAY_TASKBAR);
|
||||
addInScreenFromBind(folderIcon, info);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ class ItemInflater<T>(
|
||||
R.layout.app_pair_icon,
|
||||
context,
|
||||
parent,
|
||||
item as FolderInfo
|
||||
item as FolderInfo,
|
||||
BubbleTextView.DISPLAY_WORKSPACE
|
||||
)
|
||||
Favorites.ITEM_TYPE_APPWIDGET,
|
||||
Favorites.ITEM_TYPE_CUSTOM_APPWIDGET ->
|
||||
|
||||
Reference in New Issue
Block a user