chromium/chrome/android/java/src/org/chromium/chrome/browser/ChromeActionModeHandler.java

// Copyright 2020 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;

import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.graphics.Rect;
import android.os.Handler;
import android.text.TextUtils;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.readaloud.ReadAloudController;
import org.chromium.chrome.browser.selection.ChromeSelectionDropdownMenuDelegate;
import org.chromium.chrome.browser.share.ChromeShareExtras;
import org.chromium.chrome.browser.share.ShareDelegate;
import org.chromium.chrome.browser.share.ShareDelegate.ShareOrigin;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.chrome.browser.tab.TabWebContentsObserver;
import org.chromium.chrome.browser.user_education.IPHCommandBuilder;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.components.browser_ui.share.ShareParams;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.content_public.browser.ActionModeCallback;
import org.chromium.content_public.browser.ActionModeCallbackHelper;
import org.chromium.content_public.browser.SelectionPopupController;
import org.chromium.content_public.browser.WebContents;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

/** A class that handles selection action mode for the active {@link Tab}. */
public class ChromeActionModeHandler {
    /** Observes the active WebContents being initialized into a Tab. */
    private final Callback<WebContents> mInitWebContentsObserver;

    private final ActivityTabProvider.ActivityTabTabObserver mActivityTabTabObserver;

    private Tab mActiveTab;

    /**
     * @param activityTabProvider {@link ActivityTabProvider} instance.
     * @param actionBarObserver observer called when the contextual action bar's visibility has
     *     changed.
     * @param showWebSearch Whether 'Web Search' option will be shown.
     * @param searchCallback Callback to run when search action is selected in the action mode.
     * @param shareDelegateSupplier The {@link Supplier} of the {@link ShareDelegate} that will be
     *     notified when a share action is performed.
     */
    public ChromeActionModeHandler(
            ActivityTabProvider activityTabProvider,
            Callback<String> searchCallback,
            boolean showWebSearch,
            Supplier<ShareDelegate> shareDelegateSupplier,
            Supplier<ReadAloudController> readAloudControllerSupplier) {
        mInitWebContentsObserver =
                (webContents) -> {
                    SelectionPopupController spc =
                            SelectionPopupController.fromWebContents(webContents);
                    spc.setActionModeCallback(
                            new ChromeActionModeCallback(
                                    mActiveTab,
                                    webContents,
                                    searchCallback,
                                    showWebSearch,
                                    shareDelegateSupplier,
                                    readAloudControllerSupplier));
                    spc.setDropdownMenuDelegate(new ChromeSelectionDropdownMenuDelegate());
                };

        mActivityTabTabObserver =
                new ActivityTabProvider.ActivityTabTabObserver(activityTabProvider) {
                    @Override
                    public void onObservingDifferentTab(Tab tab, boolean hint) {
                        // ActivityTabProvider will null out the tab passed to
                        // onObservingDifferentTab when the tab is non-interactive (e.g. when
                        // entering the TabSwitcher), but in those cases we actually still want to
                        // use the most recently selected tab.
                        if (tab == null || tab == mActiveTab) return;

                        if (mActiveTab != null && mActiveTab.isInitialized()) {
                            TabWebContentsObserver.from(mActiveTab)
                                    .removeInitWebContentsObserver(mInitWebContentsObserver);
                        }
                        mActiveTab = tab;
                        TabWebContentsObserver.from(tab)
                                .addInitWebContentsObserver(mInitWebContentsObserver);
                    }
                };
    }

    @VisibleForTesting
    static class ChromeActionModeCallback extends ActionModeCallback {
        /**
         * Android Intent size limitations prevent sending over a megabyte of data. Limit
         * query lengths to 100kB because other things may be added to the Intent.
         */
        private static final int MAX_SHARE_QUERY_LENGTH_CHARS = 100000;

        private final Tab mTab;
        private final ActionModeCallbackHelper mHelper;
        private final Callback<String> mSearchCallback;
        private final boolean mShowWebSearch;
        private final Supplier<ShareDelegate> mShareDelegateSupplier;
        private final Supplier<ReadAloudController> mReadAloudControllerSupplier;

        // Used for recording UMA histograms.
        private long mContextMenuStartTime;

        ChromeActionModeCallback(
                Tab tab,
                WebContents webContents,
                Callback<String> searchCallback,
                boolean showWebSearch,
                Supplier<ShareDelegate> shareDelegateSupplier,
                Supplier<ReadAloudController> readAloudControllerSupplier) {
            mTab = tab;
            mHelper = getActionModeCallbackHelper(webContents);
            mShowWebSearch = showWebSearch;
            mSearchCallback = searchCallback;
            mShareDelegateSupplier = shareDelegateSupplier;
            mReadAloudControllerSupplier = readAloudControllerSupplier;
        }

        @VisibleForTesting
        protected ActionModeCallbackHelper getActionModeCallbackHelper(WebContents webContents) {
            return SelectionPopupController.fromWebContents(webContents)
                    .getActionModeCallbackHelper();
        }

        @Override
        public boolean onCreateActionMode(ActionMode mode, Menu menu) {
            mContextMenuStartTime = System.currentTimeMillis();

            int allowedActionModes =
                    ActionModeCallbackHelper.MENU_ITEM_PROCESS_TEXT
                            | ActionModeCallbackHelper.MENU_ITEM_SHARE;
            // Disable options that expose additional Chrome functionality prior to the FRE being
            // completed (i.e. creation of a new tab).
            if (FirstRunStatus.getFirstRunFlowComplete() && mShowWebSearch) {
                allowedActionModes |= ActionModeCallbackHelper.MENU_ITEM_WEB_SEARCH;
            }
            mHelper.setAllowedMenuItems(allowedActionModes);

            mHelper.onCreateActionMode(mode, menu);
            return true;
        }

        @Override
        public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
            recordUserAction();
            boolean res = mHelper.onPrepareActionMode(mode, menu);
            Set<String> browsers = getPackageNames(PackageManagerUtils.queryAllWebBrowsersInfo());
            Set<String> launchers = getPackageNames(PackageManagerUtils.queryAllLaunchersInfo());
            for (int i = 0; i < menu.size(); i++) {
                MenuItem item = menu.getItem(i);
                if (item.getGroupId() != R.id.select_action_menu_text_processing_items
                        || item.getIntent() == null
                        || item.getIntent().getComponent() == null) {
                    continue;
                }
                String packageName = item.getIntent().getComponent().getPackageName();
                // Exclude actions from browsers and system launchers. https://crbug.com/850195
                if (browsers.contains(packageName) || launchers.contains(packageName)) {
                    item.setVisible(false);
                }
            }
            if (menu.findItem(R.id.select_action_menu_share) != null
                    && mode.getType() == ActionMode.TYPE_FLOATING) {
                showShareIph();
            }
            return res;
        }

        private void showShareIph() {
            View view = mTab.getView();
            int padding =
                    view.getResources()
                            .getDimensionPixelSize(R.dimen.iph_shared_highlighting_padding_top);
            Rect anchorRect = new Rect(view.getWidth() / 2, padding, view.getWidth() / 2, padding);
            UserEducationHelper mUserEducationHelper =
                    new UserEducationHelper(
                            TabUtils.getActivity(mTab), mTab.getProfile(), new Handler());
            mUserEducationHelper.requestShowIPH(
                    new IPHCommandBuilder(
                                    view.getResources(),
                                    FeatureConstants.SHARED_HIGHLIGHTING_BUILDER_FEATURE,
                                    R.string.iph_shared_highlighting_builder,
                                    R.string.iph_shared_highlighting_builder)
                            .setAnchorRect(anchorRect)
                            .setAnchorView(view)
                            .setRemoveArrow(true)
                            .build());
        }

        @Override
        public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
            if (!mHelper.isActionModeValid()) return true;

            ReadAloudController readAloud = mReadAloudControllerSupplier.get();
            if (readAloud != null) {
                readAloud.maybePauseForOutgoingIntent(item.getIntent());
            }

            return handleItemClick(item.getItemId()) || mHelper.onActionItemClicked(mode, item);
        }

        @Override
        public boolean onDropdownItemClicked(
                int groupId,
                int id,
                @Nullable Intent intent,
                @Nullable View.OnClickListener clickListener) {
            boolean res =
                    handleItemClick(id)
                            || mHelper.onDropdownItemClicked(groupId, id, intent, clickListener);
            // We will always dismiss the drop-down menu here.
            mHelper.dismissMenu();
            return res;
        }

        private boolean handleItemClick(int id) {
            if (id == R.id.select_action_menu_web_search) {
                final String selectedText = mHelper.getSelectedText();
                Callback<Boolean> callback =
                        result -> {
                            if (result != null && result) search(selectedText);
                        };
                LocaleManager.getInstance()
                        .showSearchEnginePromoIfNeeded(TabUtils.getActivity(mTab), callback);
                mHelper.dismissMenu();
                return true;
            } else if (mShareDelegateSupplier.get() != null
                    && id == R.id.select_action_menu_share) {
                RecordUserAction.record(SelectionPopupController.UMA_MOBILE_ACTION_MODE_SHARE);
                RecordHistogram.recordMediumTimesHistogram(
                        "ContextMenu.TimeToSelectShare",
                        System.currentTimeMillis() - mContextMenuStartTime);
                mShareDelegateSupplier
                        .get()
                        .share(
                                new ShareParams.Builder(
                                                mTab.getWindowAndroid(),
                                                /* url= */ "",
                                                /* title= */ "")
                                        .setText(sanitizeTextForShare(mHelper.getSelectedText()))
                                        .build(),
                                new ChromeShareExtras.Builder()
                                        .setSaveLastUsed(true)
                                        .setRenderFrameHost(mHelper.getRenderFrameHost())
                                        .setDetailedContentType(
                                                ChromeShareExtras.DetailedContentType
                                                        .HIGHLIGHTED_TEXT)
                                        .build(),
                                ShareOrigin.MOBILE_ACTION_MODE);
                return true;
            }
            return false;
        }

        @Override
        public void onDestroyActionMode(ActionMode mode) {
            mHelper.onDestroyActionMode();
        }

        @Override
        public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
            mHelper.onGetContentRect(mode, view, outRect);
        }

        private Set<String> getPackageNames(List<ResolveInfo> list) {
            Set<String> set = new HashSet<>();
            for (var info : list) {
                set.add(info.activityInfo.packageName);
            }
            return set;
        }

        private void search(String searchText) {
            RecordUserAction.record("MobileActionMode.WebSearch");
            mSearchCallback.onResult(searchText);
        }

        private void recordUserAction() {
            RecordUserAction.record("MobileActionBarShown.Floating");
        }

        private static String sanitizeTextForShare(String text) {
            if (TextUtils.isEmpty(text) || text.length() < MAX_SHARE_QUERY_LENGTH_CHARS) {
                return text;
            }
            return text.substring(0, MAX_SHARE_QUERY_LENGTH_CHARS) + "…";
        }
    }
}