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

// Copyright 2022 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.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;

import androidx.annotation.IntDef;
import androidx.appcompat.content.res.AppCompatResources;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tasks.tab_management.TabUiMetricsHelper.TabListEditorActionMetricGroups;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.browser_ui.share.ShareImageFileUtils;
import org.chromium.components.browser_ui.share.ShareParams;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.url.GURL;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;

/** Share action for the {@link TabListEditorMenu}. */
public class TabListEditorShareAction extends TabListEditorAction {
    private static final List<String> UNSUPPORTED_SCHEMES =
            new ArrayList<>(
                    Arrays.asList(
                            UrlConstants.CHROME_SCHEME,
                            UrlConstants.CHROME_NATIVE_SCHEME,
                            ContentUrlConstants.ABOUT_SCHEME));
    private static Callback<Intent> sIntentCallbackForTesting;
    private Context mContext;
    private boolean mSkipUrlCheckForTesting;
    private BroadcastReceiver mBroadcastReceiver;

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @IntDef({
        TabListEditorShareActionState.UNKNOWN,
        TabListEditorShareActionState.SUCCESS,
        TabListEditorShareActionState.ALL_TABS_FILTERED,
        TabListEditorShareActionState.NUM_ENTRIES
    })
    public @interface TabListEditorShareActionState {
        int UNKNOWN = 0;
        int SUCCESS = 1;
        int ALL_TABS_FILTERED = 2;

        // Be sure to also update enums.xml when updating these values.
        int NUM_ENTRIES = 3;
    }

    /**
     * Create an action for sharing tabs.
     * @param context for loading resources.
     * @param showMode whether to show an action view.
     * @param buttonType the type of the action view.
     * @param iconPosition the position of the icon in the action view.
     */
    public static TabListEditorAction createAction(
            Context context,
            @ShowMode int showMode,
            @ButtonType int buttonType,
            @IconPosition int iconPosition) {
        Drawable drawable =
                AppCompatResources.getDrawable(context, R.drawable.tab_list_editor_share_icon);
        return new TabListEditorShareAction(
                context, showMode, buttonType, iconPosition, drawable);
    }

    private TabListEditorShareAction(
            Context context,
            @ShowMode int showMode,
            @ButtonType int buttonType,
            @IconPosition int iconPosition,
            Drawable drawable) {
        super(
                R.id.tab_list_editor_share_menu_item,
                showMode,
                buttonType,
                iconPosition,
                R.plurals.tab_selection_editor_share_tabs_action_button,
                R.plurals.accessibility_tab_selection_editor_share_tabs_action_button,
                drawable);
        mContext = context;
        mBroadcastReceiver =
                new BroadcastReceiver() {
                    @Override
                    public void onReceive(Context context, Intent intent) {
                        context.unregisterReceiver(mBroadcastReceiver);
                        // Hide the selection editor if the custom share intent is sent and received
                        // by another app, indicating that the user has completed the share tabs
                        // workflow.
                        getActionDelegate().hideByAction();
                    }
                };
    }

    @Override
    public void onSelectionStateChange(List<Integer> tabIds) {
        boolean enableShare = false;
        List<Tab> selectedTabs = getTabsOrTabsAndRelatedTabsFromSelection();

        for (Tab tab : selectedTabs) {
            if (!shouldFilterUrl(tab.getUrl())) {
                enableShare = true;
                break;
            }
        }

        int size = editorSupportsActionOnRelatedTabs() ? selectedTabs.size() : tabIds.size();
        setEnabledAndItemCount(enableShare, size);
    }

    @Override
    public boolean performAction(List<Tab> tabs) {
        assert !tabs.isEmpty() : "Share action should not be enabled for no tabs.";

        TabList tabList = getTabGroupModelFilter().getTabModel();
        List<Integer> sortedTabIndexList = filterTabs(tabs, tabList);

        if (sortedTabIndexList.size() == 0) {
            TabUiMetricsHelper.recordShareStateHistogram(
                    TabListEditorShareActionState.ALL_TABS_FILTERED);
            return false;
        }

        boolean isOnlyOneTab = (sortedTabIndexList.size() == 1);
        String tabText =
                isOnlyOneTab ? "" : getTabListStringForSharing(sortedTabIndexList, tabList);
        String tabTitle =
                isOnlyOneTab ? tabList.getTabAt(sortedTabIndexList.get(0)).getTitle() : "";
        String tabUrl =
                isOnlyOneTab ? tabList.getTabAt(sortedTabIndexList.get(0)).getUrl().getSpec() : "";
        @TabListEditorActionMetricGroups
        int actionId =
                isOnlyOneTab
                        ? TabListEditorActionMetricGroups.SHARE_TAB
                        : TabListEditorActionMetricGroups.SHARE_TABS;

        ShareParams shareParams =
                new ShareParams.Builder(
                                tabList.getTabAt(sortedTabIndexList.get(0)).getWindowAndroid(),
                                tabTitle,
                                tabUrl)
                        .setText(tabText)
                        .build();

        final Intent shareIntent = new Intent(Intent.ACTION_SEND);
        shareIntent.putExtra(Intent.EXTRA_TEXT, shareParams.getTextAndUrl());
        shareIntent.setType("text/plain");
        var context = mContext;
        var resources = context.getResources();
        shareIntent.putExtra(
                Intent.EXTRA_TITLE,
                resources.getQuantityString(
                        R.plurals.tab_selection_editor_share_sheet_preview_message,
                        sortedTabIndexList.size(),
                        sortedTabIndexList.size()));
        shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

        float padding =
                resources.getDimension(
                        R.dimen.tab_list_editor_share_sheet_preview_thumbnail_padding);
        Drawable drawable =
                new InsetDrawable(
                        AppCompatResources.getDrawable(context, R.drawable.chrome_sync_logo),
                        (int) padding);

        // Create a custom share intent and receiver to assess if another app receives the share
        // intent sent from the tab selection editor.
        Intent receiver = new Intent("SHARE_ACTION");
        PendingIntent pendingIntent =
                PendingIntent.getBroadcast(context, 0, receiver, PendingIntent.FLAG_IMMUTABLE);
        ContextUtils.registerNonExportedBroadcastReceiver(
                context, mBroadcastReceiver, new IntentFilter("SHARE_ACTION"));
        createShareableImageAndSendIntent(shareIntent, drawable, actionId, pendingIntent);
        return true;
    }

    @Override
    public boolean shouldHideEditorAfterAction() {
        // Ensure the selection editor stays open when the user is interacting with the share
        // sheet in case they decide to leave and go back to the selection editor.
        return false;
    }

    private void createShareableImageAndSendIntent(
            Intent shareIntent,
            Drawable drawable,
            @TabListEditorActionMetricGroups int actionId,
            PendingIntent pendingIntent) {
        PostTask.postTask(
                TaskTraits.USER_BLOCKING_MAY_BLOCK,
                () -> {
                    // Allotted thumbnail size is approx. 72 dp, with the icon left at default size.
                    // The padding is adjusted accordingly, taking into account the scaling factor.
                    Bitmap bitmap =
                            Bitmap.createBitmap(
                                    drawable.getIntrinsicWidth(),
                                    drawable.getIntrinsicHeight(),
                                    Bitmap.Config.ARGB_8888);
                    Canvas canvas = new Canvas(bitmap);
                    drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
                    drawable.draw(canvas);

                    ShareImageFileUtils.generateTemporaryUriFromBitmap(
                            mContext.getResources()
                                    .getString(
                                            R.string
                                                    .tab_selection_editor_share_sheet_preview_thumbnail),
                            bitmap,
                            uri -> {
                                bitmap.recycle();
                                PostTask.postTask(
                                        TaskTraits.UI_DEFAULT,
                                        () -> {
                                            shareIntent.setClipData(ClipData.newRawUri("", uri));
                                            mContext.startActivity(
                                                    Intent.createChooser(
                                                            shareIntent,
                                                            null,
                                                            pendingIntent.getIntentSender()));
                                            TabUiMetricsHelper.recordSelectionEditorActionMetrics(
                                                    actionId);
                                            TabUiMetricsHelper.recordShareStateHistogram(
                                                    TabListEditorShareActionState.SUCCESS);
                                        });

                                if (sIntentCallbackForTesting != null) {
                                    sIntentCallbackForTesting.onResult(shareIntent);
                                }
                            });
                });
    }

    // TODO(crbug.com/40871819): Current filtering does not remove duplicates or show a "Toast" if
    // no shareable URLs are present after filtering.
    private List<Integer> filterTabs(List<Tab> tabs, TabList tabList) {
        assert tabs.size() > 0;
        List<Integer> sortedTabIndexList = new ArrayList<Integer>();

        HashSet<Tab> selectedTabs = new HashSet<Tab>(tabs);
        for (int i = 0; i < tabList.getCount(); i++) {
            Tab tab = tabList.getTabAt(i);
            if (!selectedTabs.contains(tab)) continue;

            if (!shouldFilterUrl(tab.getUrl())) {
                sortedTabIndexList.add(i);
            }
        }
        return sortedTabIndexList;
    }

    private String getTabListStringForSharing(List<Integer> sortedTabIndexList, TabList list) {
        StringBuilder sb = new StringBuilder();

        // TODO(crbug.com/40871819): Check if this string builder assembles the shareable URLs in
        // accordance with internationalization and translation standards
        for (int i = 0; i < sortedTabIndexList.size(); i++) {
            sb.append(i + 1)
                    .append(". ")
                    .append(list.getTabAt(sortedTabIndexList.get(i)).getUrl().getSpec())
                    .append("\n");
        }
        return sb.toString();
    }

    private boolean shouldFilterUrl(GURL url) {
        if (mSkipUrlCheckForTesting) return false;

        return url == null
                || !url.isValid()
                || url.isEmpty()
                || UNSUPPORTED_SCHEMES.contains(url.getScheme());
    }

    void setSkipUrlCheckForTesting(boolean skip) {
        mSkipUrlCheckForTesting = skip;
        ResettersForTesting.register(() -> mSkipUrlCheckForTesting = false);
    }

    static void setIntentCallbackForTesting(Callback<Intent> callback) {
        sIntentCallbackForTesting = callback;
        ResettersForTesting.register(() -> sIntentCallbackForTesting = null);
    }
}