chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/MultiThumbnailCardProvider.java

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.tasks.tab_management;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
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.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.Size;

import androidx.annotation.NonNull;

import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.chrome.browser.tab_ui.TabContentManager;
import org.chromium.chrome.browser.tab_ui.TabContentManagerThumbnailProvider;
import org.chromium.chrome.browser.tab_ui.TabListFaviconProvider;
import org.chromium.chrome.browser.tab_ui.TabUiThemeUtils;
import org.chromium.chrome.browser.tab_ui.ThumbnailProvider;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.url.GURL;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

/**
 * A {@link ThumbnailProvider} that will create a single Bitmap Thumbnail for all the related tabs
 * for the given tabs.
 */
public class MultiThumbnailCardProvider implements ThumbnailProvider {
    private final TabContentManager mTabContentManager;
    private final TabContentManagerThumbnailProvider mTabContentManagerThumbnailProvider;
    private final ObservableSupplier<TabModelFilter> mCurrentTabModelFilterSupplier;
    private final Callback<TabModelFilter> mOnTabModelFilterChanged = this::onTabModelFilterChanged;

    private final float mRadius;
    private final float mFaviconFrameCornerRadius;
    private final Paint mEmptyThumbnailPaint;
    private final Paint mThumbnailFramePaint;
    private final Paint mThumbnailBasePaint;
    private final Paint mTextPaint;
    private final Paint mFaviconBackgroundPaint;
    private final Paint mSelectedEmptyThumbnailPaint;
    private final Paint mSelectedTextPaint;
    private final int mFaviconBackgroundPaintColor;
    private TabListFaviconProvider mTabListFaviconProvider;
    private Context mContext;
    private final BrowserControlsStateProvider mBrowserControlsStateProvider;

    private class MultiThumbnailFetcher {
        private final Tab mInitialTab;
        private final Callback<Drawable> mResultCallback;
        private final boolean mIsTabSelected;
        private final List<Tab> mTabs = new ArrayList<>(4);
        private final AtomicInteger mThumbnailsToFetch = new AtomicInteger();

        private Canvas mCanvas;
        private Bitmap mMultiThumbnailBitmap;
        private String mText;

        private final List<Rect> mFaviconRects = new ArrayList<>(4);
        private final List<RectF> mThumbnailRects = new ArrayList<>(4);
        private final List<RectF> mFaviconBackgroundRects = new ArrayList<>(4);
        private final int mThumbnailWidth;
        private final int mThumbnailHeight;

        /**
         * Fetcher that get the thumbnail drawable depending on if the tab is selected.
         *
         * @see TabContentManager#getTabThumbnailWithCallback
         * @param initialTab Thumbnail is generated for tabs related to initialTab.
         * @param thumbnailSize Desired size of multi-thumbnail.
         * @param isTabSelected Whether the thumbnail is for a currently selected tab.
         * @param resultCallback Callback which receives generated bitmap.
         */
        MultiThumbnailFetcher(
                Tab initialTab,
                Size thumbnailSize,
                boolean isTabSelected,
                Callback<Drawable> resultCallback) {
            mResultCallback = resultCallback;
            mInitialTab = initialTab;
            mIsTabSelected = isTabSelected;

            if (thumbnailSize == null
                    || thumbnailSize.getHeight() <= 0
                    || thumbnailSize.getWidth() <= 0) {
                float expectedThumbnailAspectRatio =
                        TabUtils.getTabThumbnailAspectRatio(
                                mContext, mBrowserControlsStateProvider);
                mThumbnailWidth =
                        (int)
                                mContext.getResources()
                                        .getDimension(R.dimen.tab_grid_thumbnail_card_default_size);
                mThumbnailHeight = (int) (mThumbnailWidth / expectedThumbnailAspectRatio);
            } else {
                mThumbnailWidth = thumbnailSize.getWidth();
                mThumbnailHeight = thumbnailSize.getHeight();
            }
        }

        /** Initialize rects used for thumbnails. */
        private void initializeRects(Context context) {
            float thumbnailHorizontalPadding =
                    TabUiThemeProvider.getTabMiniThumbnailPaddingDimension(context);
            float thumbnailVerticalPadding = thumbnailHorizontalPadding;

            float centerX = mThumbnailWidth * 0.5f;
            float centerY = mThumbnailHeight * 0.5f;
            float halfThumbnailHorizontalPadding = thumbnailHorizontalPadding / 2;
            float halfThumbnailVerticalPadding = thumbnailVerticalPadding / 2;

            mThumbnailRects.add(
                    new RectF(
                            0,
                            0,
                            centerX - halfThumbnailHorizontalPadding,
                            centerY - halfThumbnailVerticalPadding));
            mThumbnailRects.add(
                    new RectF(
                            centerX + halfThumbnailHorizontalPadding,
                            0,
                            mThumbnailWidth,
                            centerY - halfThumbnailVerticalPadding));
            mThumbnailRects.add(
                    new RectF(
                            0,
                            centerY + halfThumbnailVerticalPadding,
                            centerX - halfThumbnailHorizontalPadding,
                            mThumbnailHeight));
            mThumbnailRects.add(
                    new RectF(
                            centerX + halfThumbnailHorizontalPadding,
                            centerY + halfThumbnailVerticalPadding,
                            mThumbnailWidth,
                            mThumbnailHeight));

            // Initialize Rects for favicons and favicon frame.
            final float halfFaviconFrameSize =
                    mContext.getResources()
                                    .getDimension(R.dimen.tab_grid_thumbnail_favicon_frame_size)
                            / 2f;
            float thumbnailFaviconPaddingFromBackground =
                    mContext.getResources()
                            .getDimension(R.dimen.tab_grid_thumbnail_favicon_padding_from_frame);
            for (int i = 0; i < 4; i++) {
                RectF thumbnailRect = mThumbnailRects.get(i);

                float thumbnailRectCenterX = thumbnailRect.centerX();
                float thumbnailRectCenterY = thumbnailRect.centerY();
                RectF faviconBackgroundRect =
                        new RectF(
                                thumbnailRectCenterX,
                                thumbnailRectCenterY,
                                thumbnailRectCenterX,
                                thumbnailRectCenterY);
                faviconBackgroundRect.inset(-halfFaviconFrameSize, -halfFaviconFrameSize);
                mFaviconBackgroundRects.add(faviconBackgroundRect);

                RectF faviconRectF = new RectF(faviconBackgroundRect);
                faviconRectF.inset(
                        thumbnailFaviconPaddingFromBackground,
                        thumbnailFaviconPaddingFromBackground);
                Rect faviconRect = new Rect();
                faviconRectF.roundOut(faviconRect);
                mFaviconRects.add(faviconRect);
            }
        }

        private void initializeAndStartFetching(Tab initialTab) {
            // Initialize mMultiThumbnailBitmap.
            mMultiThumbnailBitmap =
                    Bitmap.createBitmap(mThumbnailWidth, mThumbnailHeight, Bitmap.Config.ARGB_8888);
            mCanvas = new Canvas(mMultiThumbnailBitmap);
            mCanvas.drawColor(Color.TRANSPARENT);

            // Initialize Tabs.
            List<Tab> relatedTabList =
                    mCurrentTabModelFilterSupplier.get().getRelatedTabList(initialTab.getId());
            if (relatedTabList.size() <= 4) {
                int thumbnailCount = relatedTabList.size();
                mThumbnailsToFetch.set(thumbnailCount);

                mTabs.add(initialTab);

                for (int i = 0; i < thumbnailCount; i++) {
                    if (relatedTabList.get(i) == initialTab) continue;

                    mTabs.add(relatedTabList.get(i));
                }
                for (int i = 0; i < 4 - thumbnailCount; i++) {
                    mTabs.add(null);
                }
            } else {
                int thumbnailCount = 3;
                mText = "+" + (relatedTabList.size() - thumbnailCount);
                mThumbnailsToFetch.set(thumbnailCount);

                mTabs.add(initialTab);

                for (int i = 0; i < thumbnailCount; i++) {
                    if (relatedTabList.get(i) == initialTab) continue;

                    mTabs.add(relatedTabList.get(i));
                    if (mTabs.size() == thumbnailCount) break;
                }
                mTabs.add(null);
            }

            // Fetch and draw all.
            for (int i = 0; i < 4; i++) {
                Tab tab = mTabs.get(i);
                RectF thumbnailRect = mThumbnailRects.get(i);
                if (tab != null) {
                    final int index = i;
                    final GURL url = tab.getUrl();
                    final boolean isIncognito = tab.isIncognito();
                    final Size tabThumbnailSize =
                            new Size((int) thumbnailRect.width(), (int) thumbnailRect.height());
                    // getTabThumbnailWithCallback() might call the callback up to twice,
                    // so use |lastFavicon| to avoid fetching the favicon the second time.
                    // Fetching the favicon after getting the live thumbnail would lead to
                    // visible flicker.
                    final AtomicReference<Drawable> lastFavicon = new AtomicReference<>();
                    mTabContentManager.getTabThumbnailWithCallback(
                            tab.getId(),
                            tabThumbnailSize,
                            thumbnail -> {
                                if (tab.isClosing() || tab.isDestroyed()) return;

                                drawThumbnailBitmapOnCanvasWithFrame(thumbnail, index);
                                if (lastFavicon.get() != null) {
                                    drawFaviconThenMaybeSendBack(lastFavicon.get(), index);
                                } else {
                                    mTabListFaviconProvider.getFaviconDrawableForUrlAsync(
                                            url,
                                            isIncognito,
                                            (Drawable favicon) -> {
                                                if (tab.isClosing() || tab.isDestroyed()) return;

                                                lastFavicon.set(favicon);
                                                drawFaviconThenMaybeSendBack(favicon, index);
                                            });
                                }
                            });
                } else {
                    drawThumbnailBitmapOnCanvasWithFrame(null, i);
                    if (mText != null && i == 3) {
                        // Draw the text exactly centered on the thumbnail rect.
                        Paint textPaint = mIsTabSelected ? mSelectedTextPaint : mTextPaint;
                        mCanvas.drawText(
                                mText,
                                (thumbnailRect.left + thumbnailRect.right) / 2,
                                (thumbnailRect.top + thumbnailRect.bottom) / 2
                                        - ((mTextPaint.descent() + mTextPaint.ascent()) / 2),
                                textPaint);
                    }
                }
            }
        }

        private void drawThumbnailBitmapOnCanvasWithFrame(Bitmap thumbnail, int index) {
            final RectF rect = mThumbnailRects.get(index);
            if (thumbnail == null) {
                Paint emptyThumbnailPaint =
                        mIsTabSelected ? mSelectedEmptyThumbnailPaint : mEmptyThumbnailPaint;
                mCanvas.drawRoundRect(rect, mRadius, mRadius, emptyThumbnailPaint);
                return;
            }

            mCanvas.save();
            mCanvas.clipRect(rect);
            Matrix m = new Matrix();

            final float newWidth = rect.width();
            final float scale =
                    Math.max(
                            newWidth / thumbnail.getWidth(), rect.height() / thumbnail.getHeight());
            m.setScale(scale, scale);
            final float xOffset =
                    rect.left + (int) ((newWidth - (thumbnail.getWidth() * scale)) / 2);
            final float yOffset = rect.top;
            m.postTranslate(xOffset, yOffset);

            // Draw the base paint first and set the base for thumbnail to draw. Setting the xfer
            // mode as SRC_OVER so the thumbnail can be drawn on top of this paint. See
            // https://crbug.com/1227619.
            mThumbnailBasePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER));
            mCanvas.drawRoundRect(rect, mRadius, mRadius, mThumbnailBasePaint);

            mThumbnailBasePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
            mCanvas.drawBitmap(thumbnail, m, mThumbnailBasePaint);
            mCanvas.restore();
            thumbnail.recycle();
        }

        private void drawFaviconDrawableOnCanvasWithFrame(Drawable favicon, int index) {
            mCanvas.drawRoundRect(
                    mFaviconBackgroundRects.get(index),
                    mFaviconFrameCornerRadius,
                    mFaviconFrameCornerRadius,
                    mFaviconBackgroundPaint);
            Rect oldBounds = new Rect(favicon.getBounds());
            favicon.setBounds(mFaviconRects.get(index));
            favicon.draw(mCanvas);
            // Restore the bounds since this may be a shared drawable.
            favicon.setBounds(oldBounds);
        }

        private void drawFaviconThenMaybeSendBack(Drawable favicon, int index) {
            drawFaviconDrawableOnCanvasWithFrame(favicon, index);
            if (mThumbnailsToFetch.decrementAndGet() == 0) {
                BitmapDrawable drawable = new BitmapDrawable(mMultiThumbnailBitmap);
                PostTask.postTask(TaskTraits.UI_USER_VISIBLE, mResultCallback.bind(drawable));
            }
        }

        private void fetch() {
            initializeRects(mContext);
            initializeAndStartFetching(mInitialTab);
        }
    }

    MultiThumbnailCardProvider(
            @NonNull Context context,
            @NonNull BrowserControlsStateProvider browserControlsStateProvider,
            @NonNull TabContentManager tabContentManager,
            @NonNull ObservableSupplier<TabModelFilter> currentTabModelFilterSupplier) {
        mContext = context;
        mBrowserControlsStateProvider = browserControlsStateProvider;
        Resources resources = context.getResources();

        mTabContentManager = tabContentManager;
        mTabContentManagerThumbnailProvider =
                new TabContentManagerThumbnailProvider(tabContentManager);
        mCurrentTabModelFilterSupplier = currentTabModelFilterSupplier;
        mRadius = resources.getDimension(R.dimen.tab_list_mini_card_radius);
        mFaviconFrameCornerRadius =
                resources.getDimension(R.dimen.tab_grid_thumbnail_favicon_frame_corner_radius);

        mTabListFaviconProvider =
                new TabListFaviconProvider(context, false, R.dimen.default_favicon_corner_radius);

        // Initialize Paints to use.
        mEmptyThumbnailPaint = new Paint();
        mEmptyThumbnailPaint.setStyle(Paint.Style.FILL);
        mEmptyThumbnailPaint.setAntiAlias(true);
        mEmptyThumbnailPaint.setColor(
                TabUiThemeUtils.getMiniThumbnailPlaceholderColor(context, false, false));

        mSelectedEmptyThumbnailPaint = new Paint(mEmptyThumbnailPaint);
        mSelectedEmptyThumbnailPaint.setColor(
                TabUiThemeUtils.getMiniThumbnailPlaceholderColor(context, false, true));

        // Paint used to set base for thumbnails, in case mEmptyThumbnailPaint has transparency.
        mThumbnailBasePaint = new Paint(mEmptyThumbnailPaint);
        mThumbnailBasePaint.setColor(Color.BLACK);
        mThumbnailBasePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

        mThumbnailFramePaint = new Paint();
        mThumbnailFramePaint.setStyle(Paint.Style.STROKE);
        mThumbnailFramePaint.setStrokeWidth(
                resources.getDimension(R.dimen.tab_list_mini_card_frame_size));
        mThumbnailFramePaint.setColor(SemanticColorUtils.getDividerLineBgColor(context));
        mThumbnailFramePaint.setAntiAlias(true);

        // TODO(crbug.com/41477335): Use pre-defined styles to avoid style out of sync if any
        // text/color styles
        // changes.
        mTextPaint = new Paint();
        mTextPaint.setTextSize(resources.getDimension(R.dimen.compositor_tab_title_text_size));
        mTextPaint.setFakeBoldText(true);
        mTextPaint.setAntiAlias(true);
        mTextPaint.setTextAlign(Paint.Align.CENTER);
        mTextPaint.setColor(TabUiThemeProvider.getTabGroupNumberTextColor(context, false, false));

        mSelectedTextPaint = new Paint(mTextPaint);
        mSelectedTextPaint.setColor(
                TabUiThemeProvider.getTabGroupNumberTextColor(context, false, true));

        mFaviconBackgroundPaintColor = context.getColor(R.color.favicon_background_color);
        mFaviconBackgroundPaint = new Paint();
        mFaviconBackgroundPaint.setAntiAlias(true);
        mFaviconBackgroundPaint.setColor(mFaviconBackgroundPaintColor);
        mFaviconBackgroundPaint.setStyle(Paint.Style.FILL);
        mFaviconBackgroundPaint.setShadowLayer(
                resources.getDimension(R.dimen.tab_grid_thumbnail_favicon_background_radius),
                0,
                resources.getDimension(R.dimen.tab_grid_thumbnail_favicon_background_down_shift),
                context.getColor(R.color.modern_grey_800_alpha_38));

        mCurrentTabModelFilterSupplier.addObserver(mOnTabModelFilterChanged);
    }

    private void onTabModelFilterChanged(TabModelFilter filter) {
        boolean isIncognito = filter.isIncognito();
        mEmptyThumbnailPaint.setColor(
                TabUiThemeUtils.getMiniThumbnailPlaceholderColor(mContext, isIncognito, false));
        mTextPaint.setColor(
                TabUiThemeProvider.getTabGroupNumberTextColor(mContext, isIncognito, false));
        mThumbnailFramePaint.setColor(
                TabUiThemeProvider.getMiniThumbnailFrameColor(mContext, isIncognito));
        mFaviconBackgroundPaint.setColor(
                TabUiThemeProvider.getFaviconBackgroundColor(mContext, isIncognito));

        mSelectedEmptyThumbnailPaint.setColor(
                TabUiThemeUtils.getMiniThumbnailPlaceholderColor(mContext, isIncognito, true));
        mSelectedTextPaint.setColor(
                TabUiThemeProvider.getTabGroupNumberTextColor(mContext, isIncognito, true));
    }

    /**
     * @param regularProfile The regular profile to use for favicons.
     */
    public void initWithNative(Profile regularProfile) {
        mTabListFaviconProvider.initWithNative(regularProfile);
    }

    /** Destroy any member that needs clean up. */
    public void destroy() {
        mCurrentTabModelFilterSupplier.removeObserver(mOnTabModelFilterChanged);
    }

    @Override
    public void getTabThumbnailWithCallback(
            int tabId, Size thumbnailSize, boolean isSelected, Callback<Drawable> callback) {
        TabModelFilter filter = mCurrentTabModelFilterSupplier.get();
        assert filter.isTabModelRestored();
        Tab tab = filter.getTabModel().getTabById(tabId);
        boolean useMultiThumbnail = filter.isTabInTabGroup(tab);
        if (useMultiThumbnail) {
            assert tab != null;
            new MultiThumbnailFetcher(tab, thumbnailSize, isSelected, callback).fetch();
            return;
        }
        mTabContentManagerThumbnailProvider.getTabThumbnailWithCallback(
                tabId, thumbnailSize, isSelected, callback);
    }
}