chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabGroupOverflowMenuCoordinator.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.app.Activity;
import android.content.ComponentCallbacks;
import android.content.Context;
import android.content.res.Configuration;
import android.database.DataSetObserver;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ListView;

import androidx.annotation.DimenRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.IdRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.res.ResourcesCompat;

import org.chromium.base.Callback;
import org.chromium.base.LifetimeAssert;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.data_sharing.DataSharingService;
import org.chromium.components.data_sharing.DataSharingService.GroupDataOrFailureOutcome;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.tab_group_sync.TabGroupSyncService;
import org.chromium.ui.listmenu.BasicListMenu.ListMenuItemType;
import org.chromium.ui.listmenu.ListMenuItemProperties;
import org.chromium.ui.listmenu.ListMenuItemViewBinder;
import org.chromium.ui.listmenu.ListSectionDividerViewBinder;
import org.chromium.ui.modelutil.LayoutViewBuilder;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.ModelListAdapter;
import org.chromium.ui.widget.AnchoredPopupWindow;
import org.chromium.ui.widget.AnchoredPopupWindow.HorizontalOrientation;
import org.chromium.ui.widget.RectProvider;
import org.chromium.ui.widget.ViewRectProvider;

/**
 * A coordinator for the overflow menu in tab groups. This applies to both the TabGridDialog toolbar
 * and tab group cards on GTS. It is responsible for creating a list of menu items, setting up the
 * menu and displaying the menu.
 */
public abstract class TabGroupOverflowMenuCoordinator {
    /** Helper interface for handling menu item clicks for tab group related actions. */
    @FunctionalInterface
    public interface OnItemClickedCallback {
        void onClick(@IdRes int menuId, int tabId, @Nullable String collaborationId);
    }

    private static class OverflowMenuHolder {
        private static final int INVALID_ITEM_ID = -1;
        private final Context mContext;
        private final View mContentView;
        private final ModelList mModelList = new ModelList();
        private final ComponentCallbacks mComponentCallbacks;
        private final LifetimeAssert mLifetimeAssert = LifetimeAssert.create(this);
        private AnchoredPopupWindow mMenuWindow;

        OverflowMenuHolder(
                RectProvider anchorViewRectProvider,
                boolean horizontalOverlapAnchor,
                boolean verticalOverlapAnchor,
                @StyleRes int animStyle,
                @HorizontalOrientation int horizontalOrientation,
                @LayoutRes int menuLayout,
                OnItemClickedCallback onItemClickedCallback,
                boolean isIncognito,
                int tabId,
                @Nullable String collaborationId,
                @DimenRes int popupWidthRes,
                @Nullable Callback<OverflowMenuHolder> onDismiss,
                Activity activity) {
            mContext = activity;
            mComponentCallbacks =
                    new ComponentCallbacks() {
                        @Override
                        public void onConfigurationChanged(Configuration newConfig) {
                            if (mMenuWindow == null || !mMenuWindow.isShowing()) return;
                            mMenuWindow.dismiss();
                        }

                        @Override
                        public void onLowMemory() {}
                    };
            mContext.registerComponentCallbacks(mComponentCallbacks);

            mContentView = LayoutInflater.from(mContext).inflate(menuLayout, null);

            ListView listView = mContentView.findViewById(R.id.tab_group_action_menu_list);
            ModelListAdapter adapter =
                    new ModelListAdapter(mModelList) {
                        @Override
                        public long getItemId(int position) {
                            ListItem item = (ListItem) getItem(position);
                            if (getItemViewType(position) == ListMenuItemType.MENU_ITEM) {
                                return item.model.get(ListMenuItemProperties.MENU_ITEM_ID);
                            } else {
                                return INVALID_ITEM_ID;
                            }
                        }
                    };
            adapter.registerType(
                    ListMenuItemType.MENU_ITEM,
                    new LayoutViewBuilder(R.layout.list_menu_item),
                    ListMenuItemViewBinder::binder);
            adapter.registerType(
                    ListMenuItemType.DIVIDER,
                    new LayoutViewBuilder(R.layout.list_section_divider),
                    ListSectionDividerViewBinder::bind);
            listView.setAdapter(adapter);
            listView.setOnItemClickListener(
                    (p, v, pos, id) -> {
                        onItemClickedCallback.onClick((int) id, tabId, collaborationId);
                        mMenuWindow.dismiss();
                    });

            View decorView = activity.getWindow().getDecorView();

            final @DrawableRes int bgDrawableId =
                    isIncognito ? R.drawable.menu_bg_tinted_on_dark_bg : R.drawable.menu_bg_tinted;

            mMenuWindow =
                    new AnchoredPopupWindow(
                            mContext,
                            decorView,
                            AppCompatResources.getDrawable(mContext, bgDrawableId),
                            mContentView,
                            anchorViewRectProvider);
            mMenuWindow.setFocusable(true);
            mMenuWindow.setHorizontalOverlapAnchor(horizontalOverlapAnchor);
            mMenuWindow.setVerticalOverlapAnchor(verticalOverlapAnchor);
            mMenuWindow.setPreferredHorizontalOrientation(horizontalOrientation);
            // Override animation style or animate from anchor as default.
            if (animStyle == ResourcesCompat.ID_NULL) {
                mMenuWindow.setAnimationStyle(animStyle);
            } else {
                mMenuWindow.setAnimateFromAnchor(true);
            }
            int popupWidth = mContext.getResources().getDimensionPixelSize(popupWidthRes);
            mMenuWindow.setMaxWidth(popupWidth);

            // Resize if any new elements are added.
            adapter.registerDataSetObserver(
                    new DataSetObserver() {
                        @Override
                        public void onChanged() {
                            mMenuWindow.onRectChanged();
                        }
                    });

            // When the menu is dismissed, call destroy to unregister the orientation listener.
            mMenuWindow.addOnDismissListener(
                    () -> {
                        if (onDismiss != null) {
                            onDismiss.onResult(this);
                        }
                        destroy();
                    });
        }

        ModelList getModelList() {
            return mModelList;
        }

        View getContentView() {
            return mContentView;
        }

        void show() {
            mMenuWindow.show();
        }

        void dismiss() {
            mMenuWindow.dismiss();
        }

        void destroy() {
            mContext.unregisterComponentCallbacks(mComponentCallbacks);
            // If mLifetimeAssert is GC'ed before this is called, it will throw an exception
            // with a stack trace showing the stack during LifetimeAssert.create().
            LifetimeAssert.setSafeToGc(mLifetimeAssert, true);
        }
    }

    private final @LayoutRes int mMenuLayout;
    private final OnItemClickedCallback mOnItemClickedCallback;
    private final Supplier<TabModel> mTabModelSupplier;
    private final boolean mIsTabGroupSyncEnabled;
    private final @Nullable IdentityManager mIdentityManager;
    private final @Nullable TabGroupSyncService mTabGroupSyncService;
    private final @Nullable DataSharingService mDataSharingService;
    private @Nullable OverflowMenuHolder mMenuHolder;

    /**
     * @param menuLayout The menu layout to use.
     * @param onItemClickedCallback A callback for listening to clicks.
     * @param tabModelSupplier The supplier of the tab model.
     * @param isTabGroupSyncEnabled Whether to tab group sync is enabled.
     * @param identityManager Used for checking the current account.
     * @param tabGroupSyncService Used to checking if a group is shared or synced.
     * @param dataSharingService Used for checking the user is the owner of a group.
     */
    protected TabGroupOverflowMenuCoordinator(
            @LayoutRes int menuLayout,
            OnItemClickedCallback onItemClickedCallback,
            Supplier<TabModel> tabModelSupplier,
            boolean isTabGroupSyncEnabled,
            @Nullable IdentityManager identityManager,
            @Nullable TabGroupSyncService tabGroupSyncService,
            @Nullable DataSharingService dataSharingService) {
        mMenuLayout = menuLayout;
        mOnItemClickedCallback = onItemClickedCallback;
        mTabModelSupplier = tabModelSupplier;
        mIsTabGroupSyncEnabled = isTabGroupSyncEnabled;
        mIdentityManager = identityManager;
        mTabGroupSyncService = tabGroupSyncService;
        mDataSharingService = dataSharingService;
    }

    /**
     * Implemented in {@link TabGroupContextMenuCoordinator} to initialize the custom view for the
     * tab group context menu. This method inflates necessary components, including the color picker
     * and group title text.
     *
     * @param contentView The root view of the content where the custom view will be initialized.
     * @param isIncognito Whether the current tab model is incognito or not.
     */
    protected void buildCustomView(View contentView, boolean isIncognito) {}

    /**
     * Concrete class required to define what the ModelList for the menu contains.
     *
     * @param itemList The {@link ModelList} to populate.
     * @param isIncognito Whether the current tab model is incognito or not.
     * @param isTabGroupSyncEnabled Whether to tab group sync is enabled.
     * @param hasCollaborationData Whether the menu will call buildCollaborationMenuItems after.
     */
    protected abstract void buildMenuActionItems(
            ModelList itemList,
            boolean isIncognito,
            boolean isTabGroupSyncEnabled,
            boolean hasCollaborationData);

    /**
     * Concrete class required to define what to add for collaborations.
     *
     * @param itemList The {@link ModelList} to populate.
     * @param identityManager Used for checking the current account.
     * @param outcome The outcome of fetching collaboration data.
     */
    protected abstract void buildCollaborationMenuItems(
            ModelList itemList, IdentityManager identityManager, GroupDataOrFailureOutcome outcome);

    /** Concrete class required to get a specific menu width for the menu pop up window. */
    protected abstract @DimenRes int getMenuWidth();

    // TODO(crbug.com/357878838): Pass the activity through constructor and setup test to test this
    // method
    /** See {@link #createAndShowMenu(RectProvider, int, boolean, boolean, Integer, Activity)} */
    protected void createAndShowMenu(View anchorView, int tabId, @NonNull Activity activity) {
        createAndShowMenu(
                new ViewRectProvider(anchorView),
                tabId,
                /* horizontalOverlapAnchor= */ true,
                /* verticalOverlapAnchor= */ true,
                R.style.EndIconMenuAnim,
                HorizontalOrientation.MAX_AVAILABLE_SPACE,
                activity);
    }

    /**
     * Creates a menu view and renders it within an @{@link AnchoredPopupWindow}
     *
     * @param anchorViewRectProvider Rect provider for view to anchor the menu.
     * @param tabId ID of Tab the menu needs to be shown for.
     * @param horizontalOverlapAnchor If true, horizontally overlaps menu with the anchor view.
     * @param verticalOverlapAnchor If true, vertically overlaps menu with the anchor view.
     * @param animStyle Animation style to apply for menu show/hide.
     * @param horizontalOrientation @{@link HorizontalOrientation} to use for the menu position.
     * @param activity Activity to get resources and decorView for menu.
     */
    protected void createAndShowMenu(
            RectProvider anchorViewRectProvider,
            int tabId,
            boolean horizontalOverlapAnchor,
            boolean verticalOverlapAnchor,
            @StyleRes int animStyle,
            @HorizontalOrientation int horizontalOrientation,
            @NonNull Activity activity) {
        assert mMenuHolder == null;
        boolean isIncognito = mTabModelSupplier.get().isIncognitoBranded();
        @Nullable String collaborationId = getCollaborationIdOrNull(tabId);
        mMenuHolder =
                new OverflowMenuHolder(
                        anchorViewRectProvider,
                        horizontalOverlapAnchor,
                        verticalOverlapAnchor,
                        animStyle,
                        horizontalOrientation,
                        mMenuLayout,
                        mOnItemClickedCallback,
                        isIncognito,
                        tabId,
                        collaborationId,
                        getMenuWidth(),
                        this::onDismiss,
                        activity);
        buildCustomView(mMenuHolder.getContentView(), isIncognito);
        configureMenuItems(mMenuHolder.getModelList(), isIncognito, tabId, collaborationId);
        mMenuHolder.show();
    }

    protected void onMenuDismissed() {}

    private void onDismiss(OverflowMenuHolder menuHolder) {
        assert mMenuHolder == menuHolder;
        mMenuHolder = null;
        onMenuDismissed();
    }

    private void configureMenuItems(
            ModelList modelList, boolean isIncognito, int tabId, @Nullable String collaborationId) {
        boolean hasCollaborationData =
                !TextUtils.isEmpty(collaborationId)
                        && mIdentityManager != null
                        && mDataSharingService != null;
        buildMenuActionItems(modelList, isIncognito, mIsTabGroupSyncEnabled, hasCollaborationData);
        if (hasCollaborationData) {
            mDataSharingService.readGroup(
                    collaborationId,
                    (outcome) -> buildCollaborationMenuItems(modelList, mIdentityManager, outcome));
        }
    }

    private @Nullable String getCollaborationIdOrNull(int tabId) {
        if (mTabModelSupplier == null || mTabGroupSyncService == null) {
            return null;
        } else {
            return TabShareUtils.getCollaborationIdOrNull(
                    tabId, mTabModelSupplier.get(), mTabGroupSyncService);
        }
    }

    void dismissForTesting() {
        mMenuHolder.dismiss();
    }
}