chromium/chrome/android/java/src/org/chromium/chrome/browser/share/ShareHelper.java

// Copyright 2014 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.share;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Parcelable;
import android.service.chooser.ChooserAction;
import android.text.TextUtils;
import android.util.Pair;

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

import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.crash.ChromePureJavaExceptionReporter;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.browser_ui.share.ShareParams;
import org.chromium.components.browser_ui.share.ShareParams.TargetChosenCallback;
import org.chromium.ui.base.WindowAndroid;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** A helper class that provides additional Chrome-specific share functionality. */
public class ShareHelper extends org.chromium.components.browser_ui.share.ShareHelper {
    private static final String TAG = "AndroidShare";
    // TODO(crbug.com/40063301): Remove when Android OS provides this string.
    private static final String INTENT_EXTRA_CHOOSER_CUSTOM_ACTIONS =
            "android.intent.extra.CHOOSER_CUSTOM_ACTIONS";
    private static final String INTENT_EXTRA_CHOOSER_MODIFY_SHARE_ACTION =
            "android.intent.extra.CHOOSER_MODIFY_SHARE_ACTION";
    // The max number of custom actions supported for custom actions.
    private static final int MAX_CUSTOM_ACTION_SUPPORTED = 5;
    private static final int CUSTOM_ACTION_REQUEST_CODE_BASE = 112;
    @VisibleForTesting static final String EXTRA_SHARE_CUSTOM_ACTION = "EXTRA_SHARE_CUSTOM_ACTION";

    private ShareHelper() {}

    /**
     * Shares the params using the system share sheet.
     *
     * @param params The share parameters.
     * @param profile The profile last shared component will be saved to, if |saveLastUsed| is set.
     * @param saveLastUsed True if the chosen share component should be saved for future reuse.
     */
    // TODO(crbug.com/40106499): Should be package-protected once modularization is complete.
    public static void shareWithSystemShareSheetUi(
            ShareParams params, @Nullable Profile profile, boolean saveLastUsed) {
        shareWithSystemShareSheetUi(params, profile, saveLastUsed, null);
    }

    /**
     * Shares the params using the system share sheet with custom actinos.
     * @param params The share parameters.
     * @param profile The profile last shared component will be saved to, if |saveLastUsed| is set.
     * @param saveLastUsed True if the chosen share component should be saved for future reuse.
     * @param customActionProvider List of custom actions for Android share sheet.
     */
    public static void shareWithSystemShareSheetUi(
            ShareParams params,
            @Nullable Profile profile,
            boolean saveLastUsed,
            @Nullable ChromeCustomShareAction.Provider customActionProvider) {
        assert (customActionProvider == null || ChooserActionHelper.isSupported())
                : "Custom action is not supported.";

        recordShareSource(ShareSourceAndroid.ANDROID_SHARE_SHEET);
        if (saveLastUsed) {
            params.setCallback(new SaveComponentCallback(profile, params.getCallback()));
        }
        Intent intent = getShareIntent(params);

        sendChooserIntent(params.getWindow(), intent, params.getCallback(), customActionProvider);
    }

    /**
     * Share directly with the provided share target.
     *
     * @param params The container holding the share parameters.
     * @param component The component to share to, bypassing any UI.
     * @param profile The profile last shared component will be saved to, if |saveLastUsed| is set.
     * @param saveLastUsed True if the chosen share component should be saved for future reuse.
     */
    // TODO(crbug.com/40106499): Should be package-protected once modularization is complete.
    public static void shareDirectly(
            @NonNull ShareParams params,
            @NonNull ComponentName component,
            @Nullable Profile profile,
            boolean saveLastUsed) {
        // Save the component directly without using a SaveComponentCallback.
        if (saveLastUsed) {
            setLastShareComponentName(profile, component);
        }
        Intent intent = getShareIntent(params);
        intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT | Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP);
        intent.setComponent(component);
        try {
            fireIntent(params.getWindow(), intent, null);
        } catch (ActivityNotFoundException exception) {
            // In rare cases when the component set for the send intent is not found, swallow the
            // errors.
            Log.e(TAG, exception.getMessage());
            ChromePureJavaExceptionReporter.reportJavaException(exception);
        }
    }

    /**
     * Convenience method to create an Intent to retrieve all the apps that support sharing text.
     */
    public static List<ResolveInfo> getCompatibleAppsForSharingText() {
        return PackageManagerUtils.queryIntentActivities(
                getShareTextAppCompatibilityIntent(),
                PackageManager.MATCH_DEFAULT_ONLY | PackageManager.GET_RESOLVED_FILTER);
    }

    /**
     * Convenience method to create an Intent to retrieve all the apps that support sharing {@code
     * fileContentType}.
     */
    public static List<ResolveInfo> getCompatibleAppsForSharingFiles(String fileContentType) {
        return PackageManagerUtils.queryIntentActivities(
                getShareFileAppCompatibilityIntent(fileContentType),
                PackageManager.MATCH_DEFAULT_ONLY | PackageManager.GET_RESOLVED_FILTER);
    }

    /** Gets the {@link ComponentName} of the app that was used to last share. */
    public static @Nullable ComponentName getLastShareComponentName() {
        SharedPreferencesManager preferencesManager = ChromeSharedPreferences.getInstance();
        String name =
                preferencesManager.readString(
                        ChromePreferenceKeys.SHARING_LAST_SHARED_COMPONENT_NAME, null);
        if (name == null) {
            return null;
        }
        return ComponentName.unflattenFromString(name);
    }

    /** Convenience method to retrieve the most recent app that support sharing text. */
    public static Pair<Drawable, CharSequence> getShareableIconAndNameForText() {
        return getShareableIconAndName(getShareTextAppCompatibilityIntent());
    }

    /**
     * Convenience method to retrieve the most recent app that support sharing {@code
     * fileContentType}.
     */
    public static Pair<Drawable, CharSequence> getShareableIconAndNameForFileContentType(
            String fileContentType) {
        return getShareableIconAndName(getShareFileAppCompatibilityIntent(fileContentType));
    }

    /**
     * Get the icon and name of the most recently shared app by certain app.
     * @param shareIntent Intent used to get list of apps support sharing.
     * @return The Image and the String of the recently shared Icon.
     */
    private static Pair<Drawable, CharSequence> getShareableIconAndName(Intent shareIntent) {
        Drawable directShareIcon = null;
        CharSequence directShareTitle = null;

        final ComponentName component = getLastShareComponentName();
        boolean isComponentValid = false;
        if (component != null) {
            shareIntent.setPackage(component.getPackageName());
            List<ResolveInfo> resolveInfoList =
                    PackageManagerUtils.queryIntentActivities(shareIntent, 0);
            for (ResolveInfo info : resolveInfoList) {
                ActivityInfo ai = info.activityInfo;
                if (component.equals(new ComponentName(ai.applicationInfo.packageName, ai.name))) {
                    isComponentValid = true;
                    break;
                }
            }
        }
        if (isComponentValid) {
            final PackageManager pm = ContextUtils.getApplicationContext().getPackageManager();
            try {
                // TODO(dtrainor): Make asynchronous and have a callback to update the menu.
                // https://crbug.com/729737
                directShareIcon = pm.getActivityIcon(component);
                directShareTitle = pm.getActivityInfo(component, 0).loadLabel(pm);
            } catch (NameNotFoundException exception) {
                // Use the default null values.
            }
        }

        return new Pair<>(directShareIcon, directShareTitle);
    }

    /**
     * Share directly with the last used share target, and record its share source.
     * @param params The container holding the share parameters.
     */
    static void shareWithLastUsedComponent(@NonNull ShareParams params) {
        ComponentName component = getLastShareComponentName();
        if (component == null) return;
        assert params.getCallback() == null;
        recordShareSource(ShareSourceAndroid.DIRECT_SHARE);
        shareDirectly(params, component, null, false);
    }

    /**
     * Stores the component selected for sharing last time share was called by certain app.
     *
     * This method is public since it is used in tests to avoid creating share dialog.
     * @param component The {@link ComponentName} of the app selected for sharing.
     */
    @VisibleForTesting
    public static void setLastShareComponentName(Profile profile, ComponentName component) {
        ChromeSharedPreferences.getInstance()
                .writeString(
                        ChromePreferenceKeys.SHARING_LAST_SHARED_COMPONENT_NAME,
                        component.flattenToString());
        if (profile != null) {
            ShareHistoryBridge.addShareEntry(profile, component.flattenToString());
        }
    }

    private static void sendChooserIntent(
            WindowAndroid window,
            Intent sharingIntent,
            @Nullable TargetChosenCallback callback,
            ChromeCustomShareAction.Provider customActions) {
        new CustomActionChosenReceiver(callback, customActions)
                .sendChooserIntent(window, sharingIntent);
    }

    private static Intent getShareTextAppCompatibilityIntent() {
        Intent intent = new Intent(Intent.ACTION_SEND);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
        intent.putExtra(Intent.EXTRA_SUBJECT, "");
        intent.putExtra(Intent.EXTRA_TEXT, "");
        intent.setType("text/plain");
        return intent;
    }

    /**
     * Convenience method to create an Intent to retrieve all the apps that support sharing {@code
     * fileContentType}.
     */
    private static Intent getShareFileAppCompatibilityIntent(String fileContentType) {
        Intent intent = new Intent(Intent.ACTION_SEND);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
        intent.setType(fileContentType);
        return intent;
    }

    /** Helper class for injecting extras into the sharing intents. */
    private static class CustomActionChosenReceiver extends TargetChosenReceiver {
        private final ChromeCustomShareAction.Provider mCustomActionProvider;
        private final Map<String, Runnable> mActionsMap = new HashMap<>();

        protected CustomActionChosenReceiver(
                @Nullable TargetChosenCallback callback,
                @Nullable ChromeCustomShareAction.Provider customActionProvider) {
            super(callback);
            mCustomActionProvider = customActionProvider;
        }

        // Override so this file can have access to call this protected method.
        @Override
        protected void sendChooserIntent(WindowAndroid windowAndroid, Intent sharingIntent) {
            super.sendChooserIntent(windowAndroid, sharingIntent);
        }

        @Override
        protected Intent getChooserIntent(WindowAndroid window, Intent sharingIntent) {
            Intent chooserIntent = super.getChooserIntent(window, sharingIntent);
            if (mCustomActionProvider == null || !ChooserActionHelper.isSupported()) {
                return chooserIntent;
            }

            List<ChromeCustomShareAction> chromeCustomShareActions =
                    mCustomActionProvider.getCustomActions();
            assert chromeCustomShareActions.size() <= MAX_CUSTOM_ACTION_SUPPORTED
                    : "Max number of actions supported:" + MAX_CUSTOM_ACTION_SUPPORTED;

            List<Parcelable> chooserActions = new ArrayList<>();
            Activity activity = window.getActivity().get();

            // Use different request code to avoid pending intent don't collision.
            int requestCode =
                    activity.getTaskId() * MAX_CUSTOM_ACTION_SUPPORTED
                            + CUSTOM_ACTION_REQUEST_CODE_BASE;
            for (var action : chromeCustomShareActions) {
                Parcelable chooserAction = createChooserAction(action, activity, requestCode++);
                chooserActions.add(chooserAction);
            }

            Parcelable[] customActions = chooserActions.toArray(new Parcelable[0]);
            chooserIntent.putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, customActions);

            return chooserIntent;
        }

        @Override
        protected void onReceiveInternal(Context context, Intent intent) {
            String action = IntentUtils.safeGetStringExtra(intent, EXTRA_SHARE_CUSTOM_ACTION);
            if (!TextUtils.isEmpty(action)) {
                assert mActionsMap.get(action) != null : "Action <" + action + "> does not exists.";
                mActionsMap.get(action).run();
            }
        }

        private Parcelable createChooserAction(
                ChromeCustomShareAction action, Activity activity, int requestCode) {
            Intent sendBackIntent = createSendBackIntentWithFilteredAction();
            sendBackIntent.putExtra(EXTRA_SHARE_CUSTOM_ACTION, action.key);
            // Make custom action immutable, since it doesn't need change any chooser component.
            PendingIntent pendingIntent =
                    PendingIntent.getBroadcast(
                            activity,
                            requestCode,
                            sendBackIntent,
                            PendingIntent.FLAG_CANCEL_CURRENT
                                    | PendingIntent.FLAG_ONE_SHOT
                                    | PendingIntent.FLAG_IMMUTABLE);

            Parcelable chooserAction =
                    ChooserActionHelper.newChooserAction(action.icon, action.label, pendingIntent);
            mActionsMap.put(action.key, action.runnable);

            return chooserAction;
        }
    }

    /**
     * A {@link TargetChosenCallback} that wraps another callback, forwarding calls to it, and
     * saving the chosen component.
     */
    private static class SaveComponentCallback implements TargetChosenCallback {
        private TargetChosenCallback mOriginalCallback;
        private Profile mProfile;

        public SaveComponentCallback(
                @Nullable Profile profile, @Nullable TargetChosenCallback originalCallback) {
            mOriginalCallback = originalCallback;
            mProfile = profile;
        }

        @Override
        public void onTargetChosen(ComponentName chosenComponent) {
            if (chosenComponent != null) {
                setLastShareComponentName(mProfile, chosenComponent);
            }
            if (mOriginalCallback != null) mOriginalCallback.onTargetChosen(chosenComponent);
        }

        @Override
        public void onCancel() {
            if (mOriginalCallback != null) mOriginalCallback.onCancel();
        }
    }

    /** Helper class used to build Android custom action. */
    @VisibleForTesting
    public static class ChooserActionHelper {
        static boolean isSupported() {
            return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
        }

        static Parcelable newChooserAction(Icon icon, String name, PendingIntent action) {
            if (!isSupported()) return null;
            return new ChooserAction.Builder(icon, name, action).build();
        }
    }
}