chromium/chrome/android/java/src/org/chromium/chrome/browser/suggestions/tile/TileRenderer.java

// Copyright 2017 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.suggestions.tile;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.view.LayoutInflater;
import android.view.ViewGroup;

import androidx.annotation.DrawableRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;

import org.chromium.base.TraceEvent;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.omnibox.suggestions.mostvisited.SuggestTileType;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.suggestions.ImageFetcher;
import org.chromium.chrome.browser.suggestions.SiteSuggestion;
import org.chromium.chrome.browser.suggestions.SuggestionsConfig.TileStyle;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.browser_ui.widget.RoundedIconGenerator;
import org.chromium.components.favicon.IconType;
import org.chromium.components.favicon.LargeIconBridge;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.ui.base.ViewUtils;

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Utility class that renders {@link Tile}s into a provided {@link ViewGroup}, creating and
 * manipulating the views as needed.
 */
public class TileRenderer {
    private final Context mContext;
    private final Resources.Theme mTheme;
    private RoundedIconGenerator mIconGenerator;
    private ImageFetcher mImageFetcher;

    @TileStyle private final int mStyle;
    private final int mDesiredIconSize;
    private final int mMinIconSize;
    private final float mIconCornerRadius;
    private int mTitleLinesCount;
    private boolean mNativeInitializationComplete;
    private Profile mProfile;

    @LayoutRes private final int mLayout;

    @LayoutRes private final int mTopSitesLayout;

    private class LargeIconCallbackImpl implements LargeIconBridge.LargeIconCallback {
        private final WeakReference<Tile> mTile;
        private final Runnable mLoadCompleteCallback;

        private LargeIconCallbackImpl(Tile tile, Runnable loadCompleteCallback) {
            mTile = new WeakReference<>(tile);
            mLoadCompleteCallback = loadCompleteCallback;
        }

        @Override
        public void onLargeIconAvailable(
                @Nullable Bitmap icon,
                int fallbackColor,
                boolean isFallbackColorDefault,
                @IconType int iconType) {
            Tile tile = mTile.get();
            if (tile != null) { // Do nothing if the tile was removed.
                tile.setIconType(iconType);
                if (icon == null) {
                    setTileIconFromColor(tile, fallbackColor, isFallbackColorDefault);
                } else {
                    setTileIconFromBitmap(tile, icon);
                }
                if (mLoadCompleteCallback != null) mLoadCompleteCallback.run();
            }

            mTile.clear();
        }
    }

    public TileRenderer(
            Context context, @TileStyle int style, int titleLines, ImageFetcher imageFetcher) {
        mImageFetcher = imageFetcher;
        mStyle = style;
        mTitleLinesCount = titleLines;

        mContext = context;
        Resources res = context.getResources();
        mTheme = context.getTheme();
        mDesiredIconSize = res.getDimensionPixelSize(R.dimen.tile_view_icon_size);
        mIconCornerRadius = res.getDimension(R.dimen.tile_view_icon_corner_radius);
        int minIconSize = res.getDimensionPixelSize(R.dimen.tile_view_icon_min_size);

        // On ldpi devices, mDesiredIconSize could be even smaller than the global limit.
        mMinIconSize = Math.min(mDesiredIconSize, minIconSize);

        mLayout = getLayout();
        mTopSitesLayout = getTopSitesLayout();

        int iconColor = context.getColor(R.color.default_favicon_background_color);
        int iconTextSize = res.getDimensionPixelSize(R.dimen.tile_view_icon_text_size);
        mIconGenerator =
                new RoundedIconGenerator(
                        mDesiredIconSize,
                        mDesiredIconSize,
                        mDesiredIconSize / 2,
                        iconColor,
                        iconTextSize);
    }

    /**
     * Renders tile views in the given {@link ViewGroup}, reusing existing tile views where
     * possible because view inflation and icon loading are slow.
     * @param parent The layout to render the tile views into.
     * @param sectionTiles Tiles to render.
     * @param setupDelegate Delegate used to setup callbacks and listeners for the new views.
     */
    public void renderTileSection(
            List<Tile> sectionTiles, ViewGroup parent, TileGroup.TileSetupDelegate setupDelegate) {
        try (TraceEvent e = TraceEvent.scoped("TileRenderer.renderTileSection")) {
            // Map the old tile views by url so they can be reused later.
            Map<SiteSuggestion, SuggestionsTileView> oldTileViews = new HashMap<>();
            int childCount = parent.getChildCount();
            for (int i = 0; i < childCount; i++) {
                SuggestionsTileView tileView = (SuggestionsTileView) parent.getChildAt(i);
                oldTileViews.put(tileView.getData(), tileView);
            }

            // Remove all views from the layout because even if they are reused later they'll have
            // to be added back in the correct order.
            parent.removeAllViews();

            for (Tile tile : sectionTiles) {
                SuggestionsTileView tileView = oldTileViews.get(tile.getData());
                if (tileView == null) {
                    tileView = buildTileView(tile, parent, setupDelegate);
                }

                parent.addView(tileView);
            }
        }
    }

    public void setImageFetcher(ImageFetcher imageFetcher) {
        mImageFetcher = imageFetcher;
    }

    /**
     * Override currently set maximum number of title lines.
     * @param titleLines The new max number of title lines to be shown under the tile icon.
     */
    public void setTitleLines(int titleLines) {
        mTitleLinesCount = titleLines;
    }

    /** Record that a tile was clicked for IPH reasons. */
    private void recordTileClickedForIPH(String eventName) {
        assert mProfile != null;
        Tracker tracker = TrackerFactory.getTrackerForProfile(mProfile);
        tracker.notifyEvent(eventName);
    }

    /**
     * Inflates a new tile view, initializes it, and loads an icon for it.
     * @param tile The tile that holds the data to populate the new tile view.
     * @param parentView The parent of the new tile view.
     * @param setupDelegate The delegate used to setup callbacks and listeners for the new view.
     * @return The new tile view.
     */
    @VisibleForTesting
    SuggestionsTileView buildTileView(
            Tile tile, ViewGroup parentView, TileGroup.TileSetupDelegate setupDelegate) {
        SuggestionsTileView tileView =
                (SuggestionsTileView)
                        LayoutInflater.from(parentView.getContext())
                                .inflate(mLayout, parentView, false);

        tileView.initialize(tile, mTitleLinesCount);

        if (!mNativeInitializationComplete || setupDelegate == null) {
            return tileView;
        }

        // Note: It is important that the callbacks below don't keep a reference to the tile or
        // modify them as there is no guarantee that the same tile would be used to update the view.
        updateIcon(tile, setupDelegate);
        updateContentDescription(tile, tileView);

        TileGroup.TileInteractionDelegate delegate =
                setupDelegate.createInteractionDelegate(tile, tileView);
        if (tile.getSource() == TileSource.HOMEPAGE) {
            delegate.setOnClickRunnable(
                    () -> {
                        recordTileClickedForIPH(EventConstants.HOMEPAGE_TILE_CLICKED);
                        RecordHistogram.recordEnumeratedHistogram(
                                "NewTabPage.SuggestTiles.SelectedTileType",
                                SuggestTileType.OTHER,
                                SuggestTileType.COUNT);
                    });
        } else if (isSearchTile(tile)) {
            delegate.setOnClickRunnable(
                    () -> {
                        RecordHistogram.recordEnumeratedHistogram(
                                "NewTabPage.SuggestTiles.SelectedTileType",
                                SuggestTileType.SEARCH,
                                SuggestTileType.COUNT);
                    });
            delegate.setOnRemoveRunnable(
                    () -> {
                        RecordHistogram.recordEnumeratedHistogram(
                                "NewTabPage.SuggestTiles.DeletedTileType",
                                SuggestTileType.SEARCH,
                                SuggestTileType.COUNT);
                    });
        } else {
            delegate.setOnClickRunnable(
                    () -> {
                        RecordHistogram.recordEnumeratedHistogram(
                                "NewTabPage.SuggestTiles.SelectedTileType",
                                SuggestTileType.URL,
                                SuggestTileType.COUNT);
                    });
            delegate.setOnRemoveRunnable(
                    () -> {
                        RecordHistogram.recordEnumeratedHistogram(
                                "NewTabPage.SuggestTiles.DeletedTileType",
                                SuggestTileType.URL,
                                SuggestTileType.COUNT);
                    });
        }

        tileView.setOnClickListener(delegate);
        tileView.setOnCreateContextMenuListener(delegate);

        return tileView;
    }

    /**
     * @return True, if the tile represents a Search query.
     */
    private boolean isSearchTile(Tile tile) {
        assert mProfile != null;
        TemplateUrlService searchService = TemplateUrlServiceFactory.getForProfile(mProfile);
        return searchService != null
                && searchService.isSearchResultsPageFromDefaultSearchProvider(tile.getUrl());
    }

    /**
     * Notify the component that the native initialization has completed and the component can
     * safely execute native code.
     */
    public void onNativeInitializationReady(Profile profile) {
        mNativeInitializationComplete = true;
        mProfile = profile;
    }

    /**
     * Given a Tile data and TileView, apply appropriate content description that will be announced
     * when the view is focused for accessibility. The objective of the description is to offer
     * audible guidance that helps users differentiate navigation (open www.site.com) and search
     * (search www.site.com).
     *
     * @param tile Tile data that carries information about the destination URL.
     * @param tileView The view that should receive updated content description.
     */
    private void updateContentDescription(Tile tile, SuggestionsTileView tileView) {
        if (isSearchTile(tile)) {
            tileView.setContentDescription(
                    mContext.getString(
                            R.string.accessibility_omnibox_most_visited_tile_search,
                            tile.getTitle()));
        } else {
            tileView.setContentDescription(
                    mContext.getString(
                            R.string.accessibility_omnibox_most_visited_tile_navigate,
                            tile.getTitle(),
                            tile.getUrl().getHost()));
        }
    }

    /**
     * Update tile decoration.
     *
     * @param tile Tile data that carries information about the target site.
     * @param setupDelegate The delegate used to setup callbacks and listeners for the new view.
     */
    public void updateIcon(final Tile tile, TileGroup.TileSetupDelegate setupDelegate) {
        if (isSearchTile(tile)) {
            // We already have an icon, and could trigger the update instantly.
            // Problem is, the TileView is likely not attached yet and the update would not be
            // properly reflected. Yield.
            final Runnable iconCallback = setupDelegate.createIconLoadCallback(tile);
            PostTask.postTask(
                    TaskTraits.UI_DEFAULT,
                    () -> {
                        setTileIconFromRes(tile, R.drawable.ic_suggestion_magnifier);
                        if (iconCallback != null) iconCallback.run();
                    });
        } else if (mImageFetcher != null) {
            mImageFetcher.makeLargeIconRequest(
                    tile.getUrl(),
                    mMinIconSize,
                    new LargeIconCallbackImpl(tile, setupDelegate.createIconLoadCallback(tile)));
        }
    }

    public void setTileIconFromBitmap(Tile tile, Bitmap icon) {
        int radius = Math.round(mIconCornerRadius * icon.getWidth() / mDesiredIconSize);
        RoundedBitmapDrawable roundedIcon =
                ViewUtils.createRoundedBitmapDrawable(mContext.getResources(), icon, radius);
        roundedIcon.setAntiAlias(true);
        roundedIcon.setFilterBitmap(true);

        tile.setIcon(roundedIcon);
        tile.setIconTint(null);
        tile.setType(TileVisualType.ICON_REAL);
    }

    public void setTileIconFromRes(Tile tile, @DrawableRes int res) {
        tile.setIcon(ResourcesCompat.getDrawable(mContext.getResources(), res, null));
        tile.setIconTint(ChromeColors.getSecondaryIconTint(mContext, /* isIncognito= */ false));
        tile.setType(TileVisualType.ICON_DEFAULT);
    }

    public void setTileIconFromColor(Tile tile, int fallbackColor, boolean isFallbackColorDefault) {
        mIconGenerator.setBackgroundColor(fallbackColor);
        Bitmap icon = mIconGenerator.generateIconForUrl(tile.getUrl());
        tile.setIcon(new BitmapDrawable(mContext.getResources(), icon));
        tile.setIconTint(null);
        tile.setType(
                isFallbackColorDefault ? TileVisualType.ICON_DEFAULT : TileVisualType.ICON_COLOR);
    }

    private @LayoutRes int getLayout() {
        switch (mStyle) {
            case TileStyle.MODERN:
                return R.layout.suggestions_tile_view;
            case TileStyle.MODERN_CONDENSED:
                return R.layout.suggestions_tile_view_condensed;
        }
        assert false;
        return 0;
    }

    private @LayoutRes int getTopSitesLayout() {
        switch (mStyle) {
            case TileStyle.MODERN:
                return R.layout.top_sites_tile_view;
            case TileStyle.MODERN_CONDENSED:
                return R.layout.top_sites_tile_view_condensed;
        }
        assert false;
        return 0;
    }

    public void setIconGeneratorForTesting(RoundedIconGenerator generator) {
        mIconGenerator = generator;
    }
}