blob: d83387705aaebbc80a80af75643bd97e08167469 [file] [log] [blame]
/*
* Copyright (C) 2017 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.quickstep.views;
import static android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS;
import static android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS;
import static com.android.launcher3.Utilities.comp;
import static com.android.launcher3.config.FeatureFlags.ENABLE_QUICKSTEP_LIVE_TILE;
import static com.android.systemui.shared.system.WindowManagerWrapper.WINDOWING_MODE_FULLSCREEN;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Insets;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Shader;
import android.os.Build;
import android.util.AttributeSet;
import android.util.FloatProperty;
import android.util.Property;
import android.view.Surface;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.graphics.ColorUtils;
import com.android.launcher3.BaseActivity;
import com.android.launcher3.DeviceProfile;
import com.android.launcher3.Utilities;
import com.android.launcher3.util.MainThreadInitializedObject;
import com.android.launcher3.util.SystemUiController;
import com.android.quickstep.TaskOverlayFactory.TaskOverlay;
import com.android.quickstep.views.TaskView.FullscreenDrawParams;
import com.android.systemui.shared.recents.model.Task;
import com.android.systemui.shared.recents.model.ThumbnailData;
/**
* A task in the Recents view.
*/
public class TaskThumbnailView extends View {
private static final MainThreadInitializedObject<FullscreenDrawParams> TEMP_PARAMS =
new MainThreadInitializedObject<>(FullscreenDrawParams::new);
public static final Property<TaskThumbnailView, Float> DIM_ALPHA =
new FloatProperty<TaskThumbnailView>("dimAlpha") {
@Override
public void setValue(TaskThumbnailView thumbnail, float dimAlpha) {
thumbnail.setDimAlpha(dimAlpha);
}
@Override
public Float get(TaskThumbnailView thumbnailView) {
return thumbnailView.mDimAlpha;
}
};
private final BaseActivity mActivity;
@Nullable
private TaskOverlay mOverlay;
private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mClearPaint = new Paint();
private final Paint mDimmingPaintAfterClearing = new Paint();
private final int mDimColor;
// Contains the portion of the thumbnail that is clipped when fullscreen progress = 0.
private final Rect mPreviewRect = new Rect();
private final PreviewPositionHelper mPreviewPositionHelper = new PreviewPositionHelper();
private TaskView.FullscreenDrawParams mFullscreenParams;
@Nullable
private Task mTask;
@Nullable
private ThumbnailData mThumbnailData;
@Nullable
protected BitmapShader mBitmapShader;
/** How much this thumbnail is dimmed, 0 not dimmed at all, 1 totally dimmed. */
private float mDimAlpha = 0f;
private boolean mOverlayEnabled;
public TaskThumbnailView(Context context) {
this(context, null);
}
public TaskThumbnailView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public TaskThumbnailView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint.setFilterBitmap(true);
mBackgroundPaint.setColor(Color.WHITE);
mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
mActivity = BaseActivity.fromContext(context);
// Initialize with placeholder value. It is overridden later by TaskView
mFullscreenParams = TEMP_PARAMS.get(context);
mDimColor = RecentsView.getForegroundScrimDimColor(context);
mDimmingPaintAfterClearing.setColor(mDimColor);
}
/**
* Updates the thumbnail to draw the provided task
* @param task
*/
public void bind(Task task) {
getTaskOverlay().reset();
mTask = task;
int color = task == null ? Color.BLACK : task.colorBackground | 0xFF000000;
mPaint.setColor(color);
mBackgroundPaint.setColor(color);
}
/**
* Updates the thumbnail.
* @param refreshNow whether the {@code thumbnailData} will be used to redraw immediately.
* In most cases, we use the {@link #setThumbnail(Task, ThumbnailData)}
* version with {@code refreshNow} is true. The only exception is
* in the live tile case that we grab a screenshot when user enters Overview
* upon swipe up so that a usable screenshot is accessible immediately when
* recents animation needs to be finished / cancelled.
*/
public void setThumbnail(@Nullable Task task, @Nullable ThumbnailData thumbnailData,
boolean refreshNow) {
mTask = task;
boolean thumbnailWasNull = mThumbnailData == null;
mThumbnailData =
(thumbnailData != null && thumbnailData.thumbnail != null) ? thumbnailData : null;
if (refreshNow) {
refresh(thumbnailWasNull && mThumbnailData != null);
}
}
/** See {@link #setThumbnail(Task, ThumbnailData, boolean)} */
public void setThumbnail(@Nullable Task task, @Nullable ThumbnailData thumbnailData) {
setThumbnail(task, thumbnailData, true /* refreshNow */);
}
/** Updates the shader, paint, matrix to redraw. */
public void refresh() {
refresh(false);
}
/**
* Updates the shader, paint, matrix to redraw.
* @param shouldRefreshOverlay whether to re-initialize overlay
*/
private void refresh(boolean shouldRefreshOverlay) {
if (mThumbnailData != null && mThumbnailData.thumbnail != null) {
Bitmap bm = mThumbnailData.thumbnail;
bm.prepareToDraw();
mBitmapShader = new BitmapShader(bm, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
mPaint.setShader(mBitmapShader);
updateThumbnailMatrix();
if (shouldRefreshOverlay) {
refreshOverlay();
}
} else {
mBitmapShader = null;
mThumbnailData = null;
mPaint.setShader(null);
getTaskOverlay().reset();
}
updateThumbnailPaintFilter();
}
/**
* Sets the alpha of the dim layer on top of this view.
* <p>
* If dimAlpha is 0, no dimming is applied; if dimAlpha is 1, the thumbnail will be the
* extracted background color.
*
*/
public void setDimAlpha(float dimAlpha) {
mDimAlpha = dimAlpha;
updateThumbnailPaintFilter();
}
public TaskOverlay getTaskOverlay() {
if (mOverlay == null) {
mOverlay = getTaskView().getRecentsView().getTaskOverlayFactory().createOverlay(this);
}
return mOverlay;
}
public float getDimAlpha() {
return mDimAlpha;
}
/**
* Get the scaled insets that are being used to draw the task view. This is a subsection of
* the full snapshot.
* @return the insets in snapshot bitmap coordinates.
*/
@RequiresApi(api = Build.VERSION_CODES.Q)
public Insets getScaledInsets() {
if (mThumbnailData == null) {
return Insets.NONE;
}
RectF bitmapRect = new RectF(
0, 0,
mThumbnailData.thumbnail.getWidth(), mThumbnailData.thumbnail.getHeight());
RectF viewRect = new RectF(0, 0, getMeasuredWidth(), getMeasuredHeight());
// The position helper matrix tells us how to transform the bitmap to fit the view, the
// inverse tells us where the view would be in the bitmaps coordinates. The insets are the
// difference between the bitmap bounds and the projected view bounds.
Matrix boundsToBitmapSpace = new Matrix();
mPreviewPositionHelper.getMatrix().invert(boundsToBitmapSpace);
RectF boundsInBitmapSpace = new RectF();
boundsToBitmapSpace.mapRect(boundsInBitmapSpace, viewRect);
DeviceProfile dp = mActivity.getDeviceProfile();
int leftInset = TaskView.clipLeft(dp) ? Math.round(boundsInBitmapSpace.left) : 0;
int topInset = TaskView.clipTop(dp) ? Math.round(boundsInBitmapSpace.top) : 0;
int rightInset = TaskView.clipRight(dp) ? Math.round(
bitmapRect.right - boundsInBitmapSpace.right) : 0;
int bottomInset = TaskView.clipBottom(dp)
? Math.round(bitmapRect.bottom - boundsInBitmapSpace.bottom) : 0;
return Insets.of(leftInset, topInset, rightInset, bottomInset);
}
public int getSysUiStatusNavFlags() {
if (mThumbnailData != null) {
int flags = 0;
flags |= (mThumbnailData.appearance & APPEARANCE_LIGHT_STATUS_BARS) != 0
? SystemUiController.FLAG_LIGHT_STATUS
: SystemUiController.FLAG_DARK_STATUS;
flags |= (mThumbnailData.appearance & APPEARANCE_LIGHT_NAVIGATION_BARS) != 0
? SystemUiController.FLAG_LIGHT_NAV
: SystemUiController.FLAG_DARK_NAV;
return flags;
}
return 0;
}
@Override
protected void onDraw(Canvas canvas) {
RectF currentDrawnInsets = mFullscreenParams.mCurrentDrawnInsets;
canvas.save();
canvas.scale(mFullscreenParams.mScale, mFullscreenParams.mScale);
canvas.translate(currentDrawnInsets.left, currentDrawnInsets.top);
// Draw the insets if we're being drawn fullscreen (we do this for quick switch).
drawOnCanvas(canvas,
-currentDrawnInsets.left,
-currentDrawnInsets.top,
getMeasuredWidth() + currentDrawnInsets.right,
getMeasuredHeight() + currentDrawnInsets.bottom,
mFullscreenParams.mCurrentDrawnCornerRadius);
canvas.restore();
}
public PreviewPositionHelper getPreviewPositionHelper() {
return mPreviewPositionHelper;
}
public void setFullscreenParams(TaskView.FullscreenDrawParams fullscreenParams) {
mFullscreenParams = fullscreenParams;
invalidate();
}
public void drawOnCanvas(Canvas canvas, float x, float y, float width, float height,
float cornerRadius) {
if (ENABLE_QUICKSTEP_LIVE_TILE.get()) {
if (mTask != null && getTaskView().isRunningTask() && !getTaskView().showScreenshot()) {
// TODO(b/189265196): Temporary fix to align the surface with the cutout perfectly.
// Round up only when the live tile task is displayed in Overview.
float rounding = comp(mFullscreenParams.mFullscreenProgress);
float left = x + rounding / 2;
float top = y + rounding / 2;
float right = width - rounding;
float bottom = height - rounding;
canvas.drawRoundRect(left, top, right, bottom, cornerRadius, cornerRadius,
mClearPaint);
canvas.drawRoundRect(left, top, right, bottom, cornerRadius, cornerRadius,
mDimmingPaintAfterClearing);
return;
}
}
// Always draw the background since the snapshots might be translucent or partially empty
// (For example, tasks been reparented out of dismissing split root when drag-to-dismiss
// split screen).
canvas.drawRoundRect(x, y, width, height, cornerRadius, cornerRadius, mBackgroundPaint);
final boolean drawBackgroundOnly = mTask == null || mTask.isLocked || mBitmapShader == null
|| mThumbnailData == null;
if (drawBackgroundOnly) {
return;
}
canvas.drawRoundRect(x, y, width, height, cornerRadius, cornerRadius, mPaint);
}
public TaskView getTaskView() {
return (TaskView) getParent();
}
public void setOverlayEnabled(boolean overlayEnabled) {
if (mOverlayEnabled != overlayEnabled) {
mOverlayEnabled = overlayEnabled;
refreshOverlay();
}
}
/**
* Potentially re-init the task overlay. Be cautious when calling this as the overlay may
* do processing on initialization.
*/
private void refreshOverlay() {
if (mOverlayEnabled) {
getTaskOverlay().initOverlay(mTask, mThumbnailData, mPreviewPositionHelper.mMatrix,
mPreviewPositionHelper.mIsOrientationChanged);
} else {
getTaskOverlay().reset();
}
}
private void updateThumbnailPaintFilter() {
ColorFilter filter = getColorFilter(mDimAlpha);
mBackgroundPaint.setColorFilter(filter);
int alpha = (int) (mDimAlpha * 255);
mDimmingPaintAfterClearing.setAlpha(alpha);
if (mBitmapShader != null) {
mPaint.setColorFilter(filter);
} else {
mPaint.setColorFilter(null);
mPaint.setColor(ColorUtils.blendARGB(Color.BLACK, mDimColor, alpha));
}
invalidate();
}
private void updateThumbnailMatrix() {
mPreviewPositionHelper.mIsOrientationChanged = false;
if (mBitmapShader != null && mThumbnailData != null) {
mPreviewRect.set(0, 0, mThumbnailData.thumbnail.getWidth(),
mThumbnailData.thumbnail.getHeight());
int currentRotation = getTaskView().getRecentsView().getPagedViewOrientedState()
.getRecentsActivityRotation();
boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
mPreviewPositionHelper.updateThumbnailMatrix(mPreviewRect, mThumbnailData,
getMeasuredWidth(), getMeasuredHeight(), mActivity.getDeviceProfile(),
currentRotation, isRtl);
mBitmapShader.setLocalMatrix(mPreviewPositionHelper.mMatrix);
mPaint.setShader(mBitmapShader);
}
getTaskView().updateCurrentFullscreenParams(mPreviewPositionHelper);
invalidate();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
updateThumbnailMatrix();
refreshOverlay();
}
private ColorFilter getColorFilter(float dimAmount) {
return Utilities.makeColorTintingColorFilter(mDimColor, dimAmount);
}
/**
* Returns current thumbnail or null if none is set.
*/
@Nullable
public Bitmap getThumbnail() {
if (mThumbnailData == null) {
return null;
}
return mThumbnailData.thumbnail;
}
/**
* Returns whether the snapshot is real. If the device is locked for the user of the task,
* the snapshot used will be an app-theme generated snapshot instead of a real snapshot.
*/
public boolean isRealSnapshot() {
if (mThumbnailData == null) {
return false;
}
return mThumbnailData.isRealSnapshot && !mTask.isLocked;
}
/**
* Utility class to position the thumbnail in the TaskView
*/
public static class PreviewPositionHelper {
private static final RectF EMPTY_RECT_F = new RectF();
// Contains the portion of the thumbnail that is unclipped when fullscreen progress = 1.
private final RectF mClippedInsets = new RectF();
private final Matrix mMatrix = new Matrix();
private boolean mIsOrientationChanged;
public Matrix getMatrix() {
return mMatrix;
}
/**
* Updates the matrix based on the provided parameters
*/
public void updateThumbnailMatrix(Rect thumbnailBounds, ThumbnailData thumbnailData,
int canvasWidth, int canvasHeight, DeviceProfile dp, int currentRotation,
boolean isRtl) {
boolean isRotated = false;
boolean isOrientationDifferent;
int thumbnailRotation = thumbnailData.rotation;
int deltaRotate = getRotationDelta(currentRotation, thumbnailRotation);
RectF thumbnailClipHint = new RectF();
if (TaskView.clipLeft(dp)) {
thumbnailClipHint.left = thumbnailData.insets.left;
}
if (TaskView.clipRight(dp)) {
thumbnailClipHint.right = thumbnailData.insets.right;
}
if (TaskView.clipTop(dp)) {
thumbnailClipHint.top = thumbnailData.insets.top;
}
if (TaskView.clipBottom(dp)) {
thumbnailClipHint.bottom = thumbnailData.insets.bottom;
}
float scale = thumbnailData.scale;
final float thumbnailScale;
// Landscape vs portrait change.
// Note: Disable rotation in grid layout.
boolean windowingModeSupportsRotation = !dp.isMultiWindowMode
&& thumbnailData.windowingMode == WINDOWING_MODE_FULLSCREEN
&& !dp.overviewShowAsGrid;
isOrientationDifferent = isOrientationChange(deltaRotate)
&& windowingModeSupportsRotation;
if (canvasWidth == 0 || canvasHeight == 0 || scale == 0) {
// If we haven't measured , skip the thumbnail drawing and only draw the background
// color
thumbnailScale = 0f;
} else {
// Rotate the screenshot if not in multi-window mode
isRotated = deltaRotate > 0 && windowingModeSupportsRotation;
float surfaceWidth = thumbnailBounds.width() / scale;
float surfaceHeight = thumbnailBounds.height() / scale;
float availableWidth = surfaceWidth
- (thumbnailClipHint.left + thumbnailClipHint.right);
float availableHeight = surfaceHeight
- (thumbnailClipHint.top + thumbnailClipHint.bottom);
float canvasAspect = canvasWidth / (float) canvasHeight;
float availableAspect = isRotated
? availableHeight / availableWidth
: availableWidth / availableHeight;
boolean isAspectLargelyDifferent = Utilities.isRelativePercentDifferenceGreaterThan(
canvasAspect, availableAspect, 0.1f);
if (isRotated && isAspectLargelyDifferent) {
// Do not rotate thumbnail if it would not improve fit
isRotated = false;
isOrientationDifferent = false;
}
if (isAspectLargelyDifferent) {
// Crop letterbox insets if insets isn't already clipped
if (!TaskView.clipLeft(dp)) {
thumbnailClipHint.left = thumbnailData.letterboxInsets.left;
}
if (!TaskView.clipRight(dp)) {
thumbnailClipHint.right = thumbnailData.letterboxInsets.right;
}
if (!TaskView.clipTop(dp)) {
thumbnailClipHint.top = thumbnailData.letterboxInsets.top;
}
if (!TaskView.clipBottom(dp)) {
thumbnailClipHint.bottom = thumbnailData.letterboxInsets.bottom;
}
availableWidth = surfaceWidth
- (thumbnailClipHint.left + thumbnailClipHint.right);
availableHeight = surfaceHeight
- (thumbnailClipHint.top + thumbnailClipHint.bottom);
}
final float targetW, targetH;
if (isOrientationDifferent) {
targetW = canvasHeight;
targetH = canvasWidth;
} else {
targetW = canvasWidth;
targetH = canvasHeight;
}
float targetAspect = targetW / targetH;
// Update the clipHint such that
// > the final clipped position has same aspect ratio as requested by canvas
// > first fit the width and crop the extra height
// > if that will leave empty space, fit the height and crop the width instead
float croppedWidth = availableWidth;
float croppedHeight = croppedWidth / targetAspect;
if (croppedHeight > availableHeight) {
croppedHeight = availableHeight;
if (croppedHeight < targetH) {
croppedHeight = Math.min(targetH, surfaceHeight);
}
croppedWidth = croppedHeight * targetAspect;
// One last check in case the task aspect radio messed up something
if (croppedWidth > surfaceWidth) {
croppedWidth = surfaceWidth;
croppedHeight = croppedWidth / targetAspect;
}
}
// Update the clip hints. Align to 0,0, crop the remaining.
if (isRtl) {
thumbnailClipHint.left += availableWidth - croppedWidth;
if (thumbnailClipHint.right < 0) {
thumbnailClipHint.left += thumbnailClipHint.right;
thumbnailClipHint.right = 0;
}
} else {
thumbnailClipHint.right += availableWidth - croppedWidth;
if (thumbnailClipHint.left < 0) {
thumbnailClipHint.right += thumbnailClipHint.left;
thumbnailClipHint.left = 0;
}
}
thumbnailClipHint.bottom += availableHeight - croppedHeight;
if (thumbnailClipHint.top < 0) {
thumbnailClipHint.bottom += thumbnailClipHint.top;
thumbnailClipHint.top = 0;
} else if (thumbnailClipHint.bottom < 0) {
thumbnailClipHint.top += thumbnailClipHint.bottom;
thumbnailClipHint.bottom = 0;
}
thumbnailScale = targetW / (croppedWidth * scale);
}
Rect splitScreenInsets = dp.getInsets();
if (!isRotated) {
// No Rotation
if (dp.isMultiWindowMode) {
mClippedInsets.offsetTo(splitScreenInsets.left * scale,
splitScreenInsets.top * scale);
} else {
mClippedInsets.offsetTo(thumbnailClipHint.left * scale,
thumbnailClipHint.top * scale);
}
mMatrix.setTranslate(
-thumbnailClipHint.left * scale,
-thumbnailClipHint.top * scale);
} else {
setThumbnailRotation(deltaRotate, thumbnailClipHint, scale, thumbnailBounds, dp);
}
final float widthWithInsets;
final float heightWithInsets;
if (isOrientationDifferent) {
widthWithInsets = thumbnailBounds.height() * thumbnailScale;
heightWithInsets = thumbnailBounds.width() * thumbnailScale;
} else {
widthWithInsets = thumbnailBounds.width() * thumbnailScale;
heightWithInsets = thumbnailBounds.height() * thumbnailScale;
}
mClippedInsets.left *= thumbnailScale;
mClippedInsets.top *= thumbnailScale;
if (dp.isMultiWindowMode) {
mClippedInsets.right = splitScreenInsets.right * scale * thumbnailScale;
mClippedInsets.bottom = splitScreenInsets.bottom * scale * thumbnailScale;
} else {
mClippedInsets.right = Math.max(0,
widthWithInsets - mClippedInsets.left - canvasWidth);
mClippedInsets.bottom = Math.max(0,
heightWithInsets - mClippedInsets.top - canvasHeight);
}
mMatrix.postScale(thumbnailScale, thumbnailScale);
mIsOrientationChanged = isOrientationDifferent;
}
private int getRotationDelta(int oldRotation, int newRotation) {
int delta = newRotation - oldRotation;
if (delta < 0) delta += 4;
return delta;
}
/**
* @param deltaRotation the number of 90 degree turns from the current orientation
* @return {@code true} if the change in rotation results in a shift from landscape to
* portrait or vice versa, {@code false} otherwise
*/
private boolean isOrientationChange(int deltaRotation) {
return deltaRotation == Surface.ROTATION_90 || deltaRotation == Surface.ROTATION_270;
}
private void setThumbnailRotation(int deltaRotate, RectF thumbnailInsets, float scale,
Rect thumbnailPosition, DeviceProfile dp) {
float newLeftInset = 0;
float newTopInset = 0;
float translateX = 0;
float translateY = 0;
mMatrix.setRotate(90 * deltaRotate);
switch (deltaRotate) { /* Counter-clockwise */
case Surface.ROTATION_90:
newLeftInset = thumbnailInsets.bottom;
newTopInset = thumbnailInsets.left;
translateX = thumbnailPosition.height();
break;
case Surface.ROTATION_270:
newLeftInset = thumbnailInsets.top;
newTopInset = thumbnailInsets.right;
translateY = thumbnailPosition.width();
break;
case Surface.ROTATION_180:
newLeftInset = -thumbnailInsets.top;
newTopInset = -thumbnailInsets.left;
translateX = thumbnailPosition.width();
translateY = thumbnailPosition.height();
break;
}
mClippedInsets.offsetTo(newLeftInset * scale, newTopInset * scale);
mMatrix.postTranslate(translateX, translateY);
if (TaskView.useFullThumbnail(dp)) {
mMatrix.postTranslate(-mClippedInsets.left, -mClippedInsets.top);
}
}
/**
* Insets to used for clipping the thumbnail (in case it is drawing outside its own space)
*/
public RectF getInsetsToDrawInFullscreen(DeviceProfile dp) {
return TaskView.useFullThumbnail(dp) ? mClippedInsets : EMPTY_RECT_F;
}
}
}