chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabGridViewBinder.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 static org.chromium.chrome.browser.tasks.tab_management.TabListModel.CardProperties.CARD_ALPHA;

import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.util.Size;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.view.ViewCompat;
import androidx.core.widget.ImageViewCompat;
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat;

import org.chromium.base.Callback;
import org.chromium.base.ResettersForTesting;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.chrome.browser.tab.state.ShoppingPersistedTabData;
import org.chromium.chrome.browser.tab_ui.TabListFaviconProvider;
import org.chromium.chrome.browser.tab_ui.TabThumbnailView;
import org.chromium.chrome.browser.tab_ui.TabUiThemeUtils;
import org.chromium.chrome.browser.tasks.tab_management.TabListMediator.IphProvider;
import org.chromium.chrome.browser.tasks.tab_management.TabListMediator.TabActionListener;
import org.chromium.chrome.browser.tasks.tab_management.TabListMediator.TabGroupInfo;
import org.chromium.chrome.browser.tasks.tab_management.TabProperties.TabActionState;
import org.chromium.chrome.tab_ui.R;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.widget.ChromeImageView;
import org.chromium.ui.widget.ViewLookupCachingFrameLayout;

/**
 * {@link org.chromium.ui.modelutil.SimpleRecyclerViewMcp.ViewBinder} for tab grid. This class
 * supports both full and partial updates to the {@link TabGridViewHolder}.
 */
class TabGridViewBinder {
    private static ThumbnailFetcher sThumbnailFetcherForTesting;
    private static final String SHOPPING_METRICS_IDENTIFIER = "EnterTabSwitcher";

    /**
     * Main entrypoint for binding TabGridView
     *
     * @param view The view to bind to.
     * @param model The model to bind.
     * @param viewType The view type to bind.
     */
    public static void bindTab(
            PropertyModel model, ViewGroup view, @Nullable PropertyKey propertyKey) {
        assert view instanceof ViewLookupCachingFrameLayout;
        @TabActionState Integer tabActionState = model.get(TabProperties.TAB_ACTION_STATE);
        if (tabActionState == null) {
            assert false : "TAB_ACTION_STATE must be set before initial bindTab call.";
            return;
        }

        ((TabGridView) view).setTabActionState(tabActionState);
        if (propertyKey == null) {
            onBindAll((ViewLookupCachingFrameLayout) view, model, tabActionState);
            return;
        }

        bindCommonProperties(model, (ViewLookupCachingFrameLayout) view, propertyKey);
        if (tabActionState == TabActionState.CLOSABLE) {
            bindClosableTabProperties(model, (ViewLookupCachingFrameLayout) view, propertyKey);
        } else if (tabActionState == TabActionState.SELECTABLE) {
            bindSelectableTabProperties(model, (ViewLookupCachingFrameLayout) view, propertyKey);
        } else {
            assert false : "Unsupported TabActionState provided to bindTab.";
        }
    }

    /**
     * Rebind all properties on a model to the view.
     *
     * @param view The view to bind to.
     * @param model The model to bind.
     * @param viewType The view type to bind.
     */
    private static void onBindAll(
            ViewLookupCachingFrameLayout view,
            PropertyModel model,
            @TabActionState int tabActionState) {
        for (PropertyKey propertyKey : TabProperties.ALL_KEYS_TAB_GRID) {
            bindCommonProperties(model, view, propertyKey);
            switch (tabActionState) {
                case TabProperties.TabActionState.SELECTABLE:
                    bindSelectableTabProperties(model, view, propertyKey);
                    break;
                case TabProperties.TabActionState.CLOSABLE:
                    bindClosableTabProperties(model, view, propertyKey);
                    break;
                default:
                    assert false;
            }
        }
    }

    private static void bindCommonProperties(
            PropertyModel model,
            ViewLookupCachingFrameLayout view,
            @Nullable PropertyKey propertyKey) {
        if (TabProperties.TITLE == propertyKey) {
            String title = model.get(TabProperties.TITLE);
            TextView tabTitleView = (TextView) view.fastFindViewById(R.id.tab_title);
            tabTitleView.setText(title);
            tabTitleView.setContentDescription(
                    view.getResources().getString(R.string.accessibility_tabstrip_tab, title));
        } else if (TabProperties.IS_SELECTED == propertyKey) {
            updateColor(
                    view,
                    model.get(TabProperties.IS_INCOGNITO),
                    model.get(TabProperties.IS_SELECTED));
            updateFavicon(view, model);
        } else if (TabProperties.FAVICON_FETCHER == propertyKey) {
            updateFavicon(view, model);
        } else if (TabProperties.CONTENT_DESCRIPTION_STRING == propertyKey) {
            view.setContentDescription(model.get(TabProperties.CONTENT_DESCRIPTION_STRING));
        } else if (TabProperties.GRID_CARD_SIZE == propertyKey) {
            final Size cardSize = model.get(TabProperties.GRID_CARD_SIZE);
            view.setMinimumHeight(cardSize.getHeight());
            view.setMinimumWidth(cardSize.getWidth());
            var layoutParams = view.getLayoutParams();
            layoutParams.height = cardSize.getHeight();
            layoutParams.width = cardSize.getWidth();
            view.setLayoutParams(layoutParams);
            updateThumbnail(view, model);
        } else if (TabProperties.THUMBNAIL_FETCHER == propertyKey) {
            updateThumbnail(view, model);
        } else if (TabProperties.TAB_ACTION_BUTTON_LISTENER == propertyKey) {
            setNullableClickListener(
                    model.get(TabProperties.TAB_ACTION_BUTTON_LISTENER),
                    view.fastFindViewById(R.id.action_button),
                    model);
        } else if (TabProperties.TAB_CLICK_LISTENER == propertyKey) {
            setNullableClickListener(model.get(TabProperties.TAB_CLICK_LISTENER), view, model);
        } else if (TabProperties.TAB_LONG_CLICK_LISTENER == propertyKey) {
            setNullableLongClickListener(
                    model.get(TabProperties.TAB_LONG_CLICK_LISTENER), view, model);
        }
    }

    private static void bindClosableTabProperties(
            PropertyModel model, ViewLookupCachingFrameLayout view, PropertyKey propertyKey) {
        if (CARD_ALPHA == propertyKey) {
            view.setAlpha(model.get(CARD_ALPHA));
        } else if (TabProperties.IPH_PROVIDER == propertyKey) {
            IphProvider provider = model.get(TabProperties.IPH_PROVIDER);
            if (provider != null) provider.showIPH(view.fastFindViewById(R.id.tab_thumbnail));
        } else if (TabProperties.CARD_ANIMATION_STATUS == propertyKey) {
            ((TabGridView) view)
                    .scaleTabGridCardView(model.get(TabProperties.CARD_ANIMATION_STATUS));
        } else if (TabProperties.IS_INCOGNITO == propertyKey) {
            boolean isIncognito = model.get(TabProperties.IS_INCOGNITO);
            boolean isSelected = model.get(TabProperties.IS_SELECTED);
            updateColor(view, isIncognito, isSelected);
            updateColorForActionButton(view, isIncognito, isSelected);
        } else if (TabProperties.ACCESSIBILITY_DELEGATE == propertyKey) {
            view.setAccessibilityDelegate(model.get(TabProperties.ACCESSIBILITY_DELEGATE));
        } else if (TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER == propertyKey) {
            fetchPriceDrop(model, (priceDrop) -> onPriceDropFetched(view, model, priceDrop), true);
        } else if (TabProperties.SHOULD_SHOW_PRICE_DROP_TOOLTIP == propertyKey) {
            if (model.get(TabProperties.SHOULD_SHOW_PRICE_DROP_TOOLTIP)) {
                PriceCardView priceCardView =
                        (PriceCardView) view.fastFindViewById(R.id.price_info_box_outer);
                assert priceCardView.getVisibility() == View.VISIBLE;
                LargeMessageCardView.showPriceDropTooltip(
                        priceCardView.findViewById(R.id.current_price));
            }
        } else if (TabProperties.IS_SELECTED == propertyKey) {
            updateColorForActionButton(
                    view,
                    model.get(TabProperties.IS_INCOGNITO),
                    model.get(TabProperties.IS_SELECTED));
        } else if (TabProperties.ACTION_BUTTON_DESCRIPTION_STRING == propertyKey) {
            view.fastFindViewById(R.id.action_button)
                    .setContentDescription(
                            model.get(TabProperties.ACTION_BUTTON_DESCRIPTION_STRING));
        } else if (TabProperties.QUICK_DELETE_ANIMATION_STATUS == propertyKey) {
            ((TabGridView) view)
                    .hideTabGridCardViewForQuickDelete(
                            model.get(TabProperties.QUICK_DELETE_ANIMATION_STATUS),
                            model.get(TabProperties.IS_INCOGNITO));
        } else if (TabProperties.TAB_GROUP_INFO == propertyKey
                || TabProperties.TAB_ID == propertyKey) {
            @Nullable TabGroupInfo tabGroupInfo = model.get(TabProperties.TAB_GROUP_INFO);

            // Only change the drawable if the property key in question is for tab groups.
            if (TabProperties.TAB_GROUP_INFO == propertyKey) {
                ((TabGridView) view).setTabActionButtonDrawable(tabGroupInfo.getIsTabGroup());
            }
        } else if (TabProperties.VISIBILITY == propertyKey) {
            view.setVisibility(model.get(TabProperties.VISIBILITY));
        } else if (TabProperties.TAB_ACTION_STATE == propertyKey) {
            updateColorForActionButton(
                    view,
                    model.get(TabProperties.IS_INCOGNITO),
                    model.get(TabProperties.IS_SELECTED));
        } else if (TabProperties.TAB_CARD_LABEL_DATA == propertyKey) {
            updateTabCardLabel(view, model.get(TabProperties.TAB_CARD_LABEL_DATA));
        }
    }

    private static void bindSelectableTabProperties(
            PropertyModel model, ViewLookupCachingFrameLayout view, PropertyKey propertyKey) {
        final int tabId = model.get(TabProperties.TAB_ID);

        if (TabProperties.IS_SELECTED == propertyKey) {
            updateColorForSelectionToggleButton(
                    view,
                    model.get(TabProperties.IS_INCOGNITO),
                    model.get(TabProperties.IS_SELECTED));
        } else if (TabProperties.TAB_SELECTION_DELEGATE == propertyKey) {
            ((TabGridView) view)
                    .setSelectionDelegate(model.get(TabProperties.TAB_SELECTION_DELEGATE));
            ((TabGridView) view).setItem(tabId);
        } else if (TabProperties.IS_INCOGNITO == propertyKey) {
            boolean isIncognito = model.get(TabProperties.IS_INCOGNITO);
            boolean isSelected = model.get(TabProperties.IS_SELECTED);
            updateColor(view, isIncognito, isSelected);
            updateColorForSelectionToggleButton(view, isIncognito, isSelected);
        } else if (TabProperties.TAB_ACTION_STATE == propertyKey) {
            boolean isIncognito = model.get(TabProperties.IS_INCOGNITO);
            boolean isSelected = model.get(TabProperties.IS_SELECTED);
            updateColor(view, isIncognito, isSelected);
            updateColorForSelectionToggleButton(view, isIncognito, isSelected);
        } else if (TabProperties.TAB_CARD_LABEL_DATA == propertyKey) {
            // Ignore this data for tab card labels in selectable mode.
            updateTabCardLabel(view, /* tabCardLabelData= */ null);
        }
    }

    static void setNullableClickListener(
            @Nullable TabActionListener listener,
            @NonNull View view,
            @NonNull PropertyModel propertyModel) {
        if (listener == null) {
            view.setOnClickListener(null);
        } else {
            view.setOnClickListener(
                    v -> {
                        listener.run(v, propertyModel.get(TabProperties.TAB_ID));
                    });
        }
    }

    static void setNullableLongClickListener(
            @Nullable TabActionListener listener,
            @NonNull View view,
            @NonNull PropertyModel propertyModel) {
        if (listener == null) {
            view.setOnLongClickListener(null);
        } else {
            view.setOnLongClickListener(
                    v -> {
                        listener.run(v, propertyModel.get(TabProperties.TAB_ID));
                        return true;
                    });
        }
    }

    private static void fetchPriceDrop(
            PropertyModel model,
            Callback<ShoppingPersistedTabData.PriceDrop> callback,
            boolean shouldLog) {
        if (model.get(TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER) == null) {
            callback.onResult(null);
            return;
        }
        model.get(TabProperties.SHOPPING_PERSISTED_TAB_DATA_FETCHER)
                .fetch(
                        (shoppingPersistedTabData) -> {
                            if (shoppingPersistedTabData == null) {
                                callback.onResult(null);
                                return;
                            }
                            if (shouldLog) {
                                shoppingPersistedTabData.logPriceDropMetrics(
                                        SHOPPING_METRICS_IDENTIFIER);
                            }
                            callback.onResult(shoppingPersistedTabData.getPriceDrop());
                        });
    }

    private static void onPriceDropFetched(
            ViewLookupCachingFrameLayout rootView,
            PropertyModel model,
            @Nullable ShoppingPersistedTabData.PriceDrop priceDrop) {
        if (ChromeFeatureList.isEnabled(ChromeFeatureList.DATA_SHARING)) {
            // TODO(crbug.com/361169665): Do activity updates or price drops take priority. Assume
            // activity updates win for now.
            if (model.get(TabProperties.TAB_CARD_LABEL_DATA) != null) return;

            if (priceDrop == null) {
                updateTabCardLabel(rootView, null);
                return;
            }

            TextResolver contentDescriptionResolver =
                    (context) -> {
                        return context.getResources()
                                .getString(
                                        R.string.accessibility_tab_price_card,
                                        priceDrop.previousPrice,
                                        priceDrop.price);
                    };
            PriceDropTextResolver priceDropResolver =
                    new PriceDropTextResolver(priceDrop.price, priceDrop.previousPrice);
            TabCardLabelData labelData =
                    new TabCardLabelData(
                            TabCardLabelType.PRICE_DROP,
                            priceDropResolver,
                            /* asyncImageFactory= */ null,
                            contentDescriptionResolver);
            updateTabCardLabel(rootView, labelData);
        } else {
            PriceCardView priceCardView =
                    (PriceCardView) rootView.fastFindViewById(R.id.price_info_box_outer);
            if (priceDrop == null) {
                priceCardView.setVisibility(View.GONE);
                return;
            }
            priceCardView.setPriceStrings(priceDrop.price, priceDrop.previousPrice);
            priceCardView.setVisibility(View.VISIBLE);
            priceCardView.setContentDescription(
                    rootView.getResources()
                            .getString(
                                    R.string.accessibility_tab_price_card,
                                    priceDrop.previousPrice,
                                    priceDrop.price));
        }
    }

    private static void updateThumbnail(ViewLookupCachingFrameLayout view, PropertyModel model) {
        TabThumbnailView thumbnail = (TabThumbnailView) view.fastFindViewById(R.id.tab_thumbnail);

        // To GC on hide set a background color and remove the thumbnail.
        final boolean isSelected = model.get(TabProperties.IS_SELECTED);
        thumbnail.updateThumbnailPlaceholder(model.get(TabProperties.IS_INCOGNITO), isSelected);

        final ThumbnailFetcher fetcher = model.get(TabProperties.THUMBNAIL_FETCHER);
        final Size cardSize = model.get(TabProperties.GRID_CARD_SIZE);
        if (fetcher == null || cardSize == null) {
            thumbnail.setImageDrawable(null);
            return;
        }

        // TODO(crbug.com/40882123): Consider unsetting the bitmap early to allow memory reuse if
        // needed.
        final Size thumbnailSize = TabUtils.deriveThumbnailSize(cardSize, view.getContext());
        // This callback will be made cancelable inside ThumbnailFetcher so only the latest fetch
        // request will return. When the fetcher is replaced any outbound requests are first
        // canceled inside TabListMediator so it is not necessary to do any sort of validation that
        // the callback matches the current thumbnail fetcher and grid card size.
        Callback<Drawable> callback =
                result -> {
                    if (result != null) {
                        TabUtils.setDrawableAndUpdateImageMatrix(thumbnail, result, thumbnailSize);
                    } else {
                        thumbnail.setImageDrawable(null);
                    }
                };
        if (sThumbnailFetcherForTesting != null) {
            sThumbnailFetcherForTesting.fetch(thumbnailSize, isSelected, callback);
        } else {
            fetcher.fetch(thumbnailSize, isSelected, callback);
        }
    }

    /**
     * Update the favicon drawable to use from {@link TabListFaviconProvider.TabFavicon}, and the
     * padding around it. The color work is already handled when favicon is bind in {@link
     * #bindCommonProperties}.
     */
    private static void updateFavicon(ViewLookupCachingFrameLayout rootView, PropertyModel model) {
        final TabListFaviconProvider.TabFaviconFetcher fetcher =
                model.get(TabProperties.FAVICON_FETCHER);
        if (fetcher == null) {
            setFavicon(rootView, model, null);
            return;
        }
        fetcher.fetch(
                tabFavicon -> {
                    if (fetcher != model.get(TabProperties.FAVICON_FETCHER)) return;

                    setFavicon(rootView, model, tabFavicon);
                });
    }

    /**
     * Set the favicon drawable to use from {@link TabListFaviconProvider.TabFavicon}, and the
     * padding around it. The color work is already handled when favicon is bind in {@link
     * #bindCommonProperties}.
     */
    private static void setFavicon(
            ViewLookupCachingFrameLayout rootView,
            PropertyModel model,
            TabListFaviconProvider.TabFavicon favicon) {
        ImageView faviconView = (ImageView) rootView.fastFindViewById(R.id.tab_favicon);
        if (favicon == null) {
            faviconView.setImageDrawable(null);
            faviconView.setPadding(0, 0, 0, 0);
            return;
        }

        boolean isSelected = model.get(TabProperties.IS_SELECTED);
        faviconView.setImageDrawable(
                isSelected ? favicon.getSelectedDrawable() : favicon.getDefaultDrawable());
        int padding =
                (int) TabUiThemeProvider.getTabCardTopFaviconPadding(faviconView.getContext());
        faviconView.setPadding(padding, padding, padding, padding);
    }

    private static void updateColor(
            ViewLookupCachingFrameLayout rootView, boolean isIncognito, boolean isSelected) {
        View cardView = rootView.fastFindViewById(R.id.card_view);
        TextView titleView = (TextView) rootView.fastFindViewById(R.id.tab_title);
        TabThumbnailView thumbnail =
                (TabThumbnailView) rootView.fastFindViewById(R.id.tab_thumbnail);
        ChromeImageView backgroundView =
                (ChromeImageView) rootView.fastFindViewById(R.id.background_view);

        cardView.getBackground().mutate();
        final @ColorInt int backgroundColor =
                TabUiThemeUtils.getCardViewBackgroundColor(
                        cardView.getContext(), isIncognito, isSelected);
        ViewCompat.setBackgroundTintList(cardView, ColorStateList.valueOf(backgroundColor));

        titleView.setTextColor(
                TabUiThemeUtils.getTitleTextColor(titleView.getContext(), isIncognito, isSelected));

        thumbnail.updateThumbnailPlaceholder(isIncognito, isSelected);

        ViewCompat.setBackgroundTintList(
                backgroundView,
                TabUiThemeProvider.getHoveredCardBackgroundTintList(
                        backgroundView.getContext(), isIncognito, isSelected));
    }

    private static void updateColorForActionButton(
            ViewLookupCachingFrameLayout rootView, boolean isIncognito, boolean isSelected) {
        ImageView actionButton = (ImageView) rootView.fastFindViewById(R.id.action_button);
        ImageViewCompat.setImageTintList(
                actionButton,
                TabUiThemeProvider.getActionButtonTintList(
                        actionButton.getContext(), isIncognito, isSelected));
    }

    private static void updateColorForSelectionToggleButton(
            ViewLookupCachingFrameLayout rootView, boolean isIncognito, boolean isSelected) {
        final int defaultLevel =
                rootView.getResources().getInteger(R.integer.list_item_level_default);
        final int selectedLevel =
                rootView.getResources().getInteger(R.integer.list_item_level_selected);

        ImageView actionButton = (ImageView) rootView.fastFindViewById(R.id.action_button);
        actionButton.getBackground().setLevel(isSelected ? selectedLevel : defaultLevel);
        DrawableCompat.setTintList(
                actionButton.getBackground().mutate(),
                TabUiThemeProvider.getToggleActionButtonBackgroundTintList(
                        rootView.getContext(), isIncognito, isSelected));

        // The check should be invisible if not selected.
        actionButton.getDrawable().setAlpha(isSelected ? 255 : 0);
        ImageViewCompat.setImageTintList(
                actionButton,
                isSelected
                        ? TabUiThemeProvider.getToggleActionButtonCheckedDrawableTintList(
                                rootView.getContext(), isIncognito)
                        : null);

        if (isSelected) {
            ((AnimatedVectorDrawableCompat) actionButton.getDrawable()).start();
        }
    }

    private static void updateTabCardLabel(
            ViewLookupCachingFrameLayout rootView, @Nullable TabCardLabelData tabCardLabelData) {
        @Nullable ViewStub stub = (ViewStub) rootView.fastFindViewById(R.id.tab_card_label_stub);
        TabCardLabelView labelView;
        if (stub != null) {
            if (tabCardLabelData == null) return;

            labelView = (TabCardLabelView) stub.inflate();
        } else {
            labelView = (TabCardLabelView) rootView.fastFindViewById(R.id.tab_card_label);
        }
        labelView.setData(tabCardLabelData);
    }

    static void setThumbnailFeatureForTesting(ThumbnailFetcher fetcher) {
        sThumbnailFetcherForTesting = fetcher;
        ResettersForTesting.register(() -> sThumbnailFetcherForTesting = null);
    }
}