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

// Copyright 2017 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.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager.RecentTaskInfo;
import android.app.Notification;
import android.app.SearchManager;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.text.TextUtils;

import androidx.annotation.IntDef;
import androidx.annotation.OptIn;
import androidx.browser.auth.ExperimentalAuthTab;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.browser.customtabs.CustomTabsSessionToken;
import androidx.browser.customtabs.TrustedWebUtils;
import androidx.core.os.BuildCompat;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.browserservices.SessionDataHolder;
import org.chromium.chrome.browser.browserservices.ui.splashscreen.trustedwebactivity.TwaSplashController;
import org.chromium.chrome.browser.customtabs.AuthTabIntentDataProvider;
import org.chromium.chrome.browser.customtabs.CustomTabActivity;
import org.chromium.chrome.browser.customtabs.CustomTabIntentDataProvider;
import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.firstrun.FirstRunFlowSequencer;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.intents.BrowserIntentUtils;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils;
import org.chromium.chrome.browser.notifications.NotificationPlatformBridge;
import org.chromium.chrome.browser.partnercustomizations.PartnerBrowserCustomizations;
import org.chromium.chrome.browser.searchwidget.SearchActivity;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.util.AndroidTaskUtils;
import org.chromium.chrome.browser.webapps.WebappLauncherActivity;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.ui.widget.Toast;
import org.chromium.webapk.lib.common.WebApkConstants;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Set;

/**
 * Dispatches incoming intents to the appropriate activity based on the current configuration and
 * Intent fired.
 */
public class LaunchIntentDispatcher {
    /** Extra indicating launch mode used. */
    public static final String EXTRA_LAUNCH_MODE =
            "com.google.android.apps.chrome.EXTRA_LAUNCH_MODE";

    private static final String TAG = "ActivityDispatcher";

    private final Activity mActivity;
    private Intent mIntent;

    @IntDef({Action.CONTINUE, Action.FINISH_ACTIVITY, Action.FINISH_ACTIVITY_REMOVE_TASK})
    @Retention(RetentionPolicy.SOURCE)
    public @interface Action {
        int CONTINUE = 0;
        int FINISH_ACTIVITY = 1;
        int FINISH_ACTIVITY_REMOVE_TASK = 2;
    }

    /**
     * Dispatches the intent in the context of the activity.
     * In most cases calling this method will result in starting a new activity, in which case
     * the current activity will need to be finished as per the action returned.
     *
     * @param currentActivity activity that received the intent
     * @param intent intent to dispatch
     * @return action to take
     */
    public static @Action int dispatch(Activity currentActivity, Intent intent) {
        return new LaunchIntentDispatcher(currentActivity, intent).dispatch();
    }

    /**
     * Dispatches the intent to proper tabbed activity.
     * This method is similar to {@link #dispatch()}, but only handles intents that result in
     * starting a tabbed activity (i.e. one of *TabbedActivity classes).
     *
     * @param currentActivity activity that received the intent
     * @param intent intent to dispatch
     * @return action to take
     */
    public static @Action int dispatchToTabbedActivity(Activity currentActivity, Intent intent) {
        return new LaunchIntentDispatcher(currentActivity, intent).dispatchToTabbedActivity();
    }

    /**
     * Dispatches the intent to proper tabbed activity.
     * This method is similar to {@link #dispatch()}, but only handles intents that result in
     * starting a custom tab activity.
     */
    public static @Action int dispatchToCustomTabActivity(Activity currentActivity, Intent intent) {
        LaunchIntentDispatcher dispatcher = new LaunchIntentDispatcher(currentActivity, intent);
        if (!isCustomTabIntent(dispatcher.mIntent)) return Action.CONTINUE;
        if (dispatcher.launchCustomTabActivity()) {
            return Action.FINISH_ACTIVITY;
        } else {
            return Action.CONTINUE;
        }
    }

    private LaunchIntentDispatcher(Activity activity, Intent intent) {
        mActivity = activity;
        mIntent = IntentUtils.sanitizeIntent(intent);

        // Needs to be called as early as possible, to accurately capture the
        // time at which the intent was received.
        if (mIntent != null && BrowserIntentUtils.getStartupRealtimeMillis(mIntent) == -1) {
            BrowserIntentUtils.addStartupTimestampsToIntent(mIntent);
        }
    }

    /**
     * Figure out how to route the Intent.  Because this is on the critical path to startup, please
     * avoid making the pathway any more complicated than it already is.  Make sure that anything
     * you add _absolutely has_ to be here.
     */
    private @Action int dispatch() {
        // Read partner browser customizations information asynchronously.
        // We want to initialize early because when there are no tabs to restore, we should possibly
        // show homepage, which might require reading PartnerBrowserCustomizations provider.
        PartnerBrowserCustomizations.getInstance()
                .initializeAsync(mActivity.getApplicationContext());

        boolean isCustomTabIntent = isCustomTabIntent(mIntent);

        int tabId = IntentHandler.getBringTabToFrontId(mIntent);
        boolean incognito =
                mIntent.getBooleanExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, false);

        String url = IntentHandler.getUrlFromIntent(mIntent);

        // Check if a web search Intent is being handled.
        if (url == null
                && tabId == Tab.INVALID_TAB_ID
                && !incognito
                && processWebSearchIntent(mIntent)) {
            return Action.FINISH_ACTIVITY;
        }

        // Check if a LIVE WebappActivity has to be brought back to the foreground.  We can't
        // check for a dead WebappActivity because we don't have that information without a global
        // TabManager.  If that ever lands, code to bring back any Tab could be consolidated
        // here instead of being spread between ChromeTabbedActivity and ChromeLauncherActivity.
        // https://crbug.com/443772, https://crbug.com/522918
        if (WebappLauncherActivity.bringWebappToFront(tabId)) {
            return Action.FINISH_ACTIVITY_REMOVE_TASK;
        }

        // The notification settings cog on the flipped side of Notifications and in the Android
        // Settings "App Notifications" view will open us with a specific category.
        if (mIntent.hasCategory(Notification.INTENT_CATEGORY_NOTIFICATION_PREFERENCES)) {
            NotificationPlatformBridge.launchNotificationPreferences(mIntent);
            return Action.FINISH_ACTIVITY;
        }

        // Check if we should push the user through First Run.
        if (FirstRunFlowSequencer.launch(mActivity, mIntent, /* preferLightweightFre= */ false)) {
            return Action.FINISH_ACTIVITY;
        }

        // Check if we should launch a Custom Tab.
        if (isCustomTabIntent) {
            launchCustomTabActivity();
            return Action.FINISH_ACTIVITY;
        }

        // b(357902796): Handle fall-back path for unbound WebAPKs.
        if (isWebApkIntent(mIntent) && launchWebApk()) {
            return Action.FINISH_ACTIVITY;
        }

        return dispatchToTabbedActivity();
    }

    private boolean processWebSearchIntent(Intent intent) {
        if (intent == null) return false;

        String query = null;
        final String action = intent.getAction();
        if (Intent.ACTION_SEARCH.equals(action)
                || MediaStore.INTENT_ACTION_MEDIA_SEARCH.equals(action)) {
            query = IntentUtils.safeGetStringExtra(intent, SearchManager.QUERY);
        }
        if (TextUtils.isEmpty(query)) return false;

        // Only the ChromeLauncherActivity can handle search intents. Drop the intent and abort the
        // launch.
        if (!(mActivity instanceof ChromeLauncherActivity)) return true;

        Intent searchIntent = new Intent(Intent.ACTION_WEB_SEARCH);
        searchIntent.putExtra(SearchManager.QUERY, query);

        if (PackageManagerUtils.canResolveActivity(
                searchIntent, PackageManager.GET_RESOLVED_FILTER)) {
            mActivity.startActivity(searchIntent);
        } else {
            // Phone doesn't have a WEB_SEARCH action handler, open Search Activity with
            // the given query.
            Intent searchActivityIntent = new Intent(Intent.ACTION_MAIN);
            searchActivityIntent.setClass(
                    ContextUtils.getApplicationContext(), SearchActivity.class);
            searchActivityIntent.putExtra(SearchManager.QUERY, query);
            mActivity.startActivity(searchActivityIntent);
        }
        return true;
    }

    /** When started with an intent, maybe pre-resolve the domain. */
    private void maybePrefetchDnsInBackground() {
        if (mIntent != null && Intent.ACTION_VIEW.equals(mIntent.getAction())) {
            String maybeUrl = IntentHandler.getUrlFromIntent(mIntent);
            if (maybeUrl != null) {
                WarmupManager.getInstance().maybePrefetchDnsForUrlInBackground(mActivity, maybeUrl);
            }
        }
    }

    /**
     * @return Whether the intent is for launching a Custom Tab.
     */
    @OptIn(markerClass = ExperimentalAuthTab.class)
    public static boolean isCustomTabIntent(Intent intent) {
        if (intent == null) return false;
        Log.w(
                TAG,
                "CustomTabsIntent#shouldAlwaysUseBrowserUI() = "
                        + CustomTabsIntent.shouldAlwaysUseBrowserUI(intent));
        if (CustomTabsIntent.shouldAlwaysUseBrowserUI(intent)
                || (!intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)
                        && !AuthTabIntentDataProvider.isAuthTabIntent(intent))) {
            return false;
        }
        return IntentHandler.getUrlFromIntent(intent) != null;
    }

    private static boolean isWebApkIntent(Intent intent) {
        return intent != null && intent.hasExtra(WebApkConstants.EXTRA_WEBAPK_PACKAGE_NAME);
    }

    /** Creates an Intent that can be used to launch a {@link CustomTabActivity}. */
    public static Intent createCustomTabActivityIntent(Context context, Intent intent) {
        // Use the copy constructor to carry over the myriad of extras.
        Uri uri = Uri.parse(IntentHandler.getUrlFromIntent(intent));

        Intent newIntent = new Intent(intent);
        newIntent.setAction(Intent.ACTION_VIEW);
        newIntent.setData(uri);
        newIntent.setClassName(context, CustomTabActivity.class.getName());
        // Make sure the result of the CustomTabActivity is forwarded to the client.
        newIntent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);

        // Since configureIntentForResizableCustomTab() might change the componenet/class
        // associated with the passed intent, it needs to be called after #setClassName(context,
        // CustomTabActivity.class.getName());
        CustomTabIntentDataProvider.configureIntentForResizableCustomTab(context, newIntent);

        if (clearTopIntentsForCustomTabsEnabled(intent)) {
            // Ensure the new intent is routed into the instance of CustomTabActivity in this task.
            // If the existing CustomTabActivity can't handle the intent, it will re-launch
            // the intent without these flags.
            // If you change this flow, please make sure it works correctly with
            // - "Don't keep activities",
            // - Multiple clients hosting CCTs,
            // - Multiwindow mode.
            Class<? extends Activity> handlerClass =
                    getSessionDataHolder().getActiveHandlerClassInCurrentTask(intent, context);
            if (handlerClass != null) {
                newIntent.setClassName(context, handlerClass.getName());
                newIntent.addFlags(
                        Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
            }
        }

        // If |uri| is a content:// URI, we want to propagate the URI permissions. This can't be
        // achieved by simply adding the FLAG_GRANT_READ_URI_PERMISSION to the Intent, since the
        // data URI on the Intent isn't |uri|, it just has |uri| as a query parameter.
        if (uri != null && UrlConstants.CONTENT_SCHEME.equals(uri.getScheme())) {
            String packageName = context.getPackageName();
            try {
                context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
            } catch (Exception e) {
                // SecurityException or UndeclaredThrowableException.
                // https://crbug.com/1373209
                Log.w(TAG, "Unable to grant Uri permission", e);
            }
        }

        if (CommandLine.getInstance().hasSwitch(ChromeSwitches.OPEN_CUSTOM_TABS_IN_NEW_TASK)) {
            newIntent.setFlags(newIntent.getFlags() | Intent.FLAG_ACTIVITY_NEW_TASK);
        }

        // Handle activity started in a new task.
        // See https://developer.android.com/guide/components/activities/tasks-and-back-stack
        if ((newIntent.getFlags() & Intent.FLAG_ACTIVITY_NEW_TASK) != 0
                || (newIntent.getFlags() & Intent.FLAG_ACTIVITY_NEW_DOCUMENT) != 0) {
            // If a CCT intent triggers First Run, then NEW_TASK will be automatically applied. As
            // part of that, it will inherit the EXCLUDE_FROM_RECENTS bit from
            // ChromeLauncherActivity, so explicitly remove it to ensure the CCT does not get lost
            // in recents.
            newIntent.setFlags(newIntent.getFlags() & ~Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);

            // Adjacent launch flag, if present, already would have made the launcher activity start
            // on the adajcent screen in multi-window mode. Clear it on the new Intent for the flag
            // to take effect only once.
            newIntent.setFlags(newIntent.getFlags() & ~Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);

            // Android will try to find and reuse an existing CCT activity in the background. Use
            // this flag to always start a new one instead.
            newIntent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
            // Force a new document to ensure the proper task/stack creation.
            newIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
        }

        return newIntent;
    }

    private static SessionDataHolder getSessionDataHolder() {
        return ChromeApplicationImpl.getComponent().resolveSessionDataHolder();
    }

    /**
     * Handles launching a {@link CustomTabActivity}, which will sit on top of a client's activity
     * in the same task. Returns whether an Activity was launched (or brought to the foreground).
     */
    private boolean launchCustomTabActivity() {
        CustomTabsConnection.getInstance()
                .onHandledIntent(
                        CustomTabsSessionToken.getSessionTokenFromIntent(mIntent), mIntent);

        boolean isCustomTab = true;
        if (IntentHandler.shouldIgnoreIntent(mIntent, mActivity, isCustomTab)) {
            return false;
        }

        if (!clearTopIntentsForCustomTabsEnabled(mIntent)) {
            // The old way of delivering intents relies on calling the activity directly via a
            // static reference. It doesn't allow using CLEAR_TOP, and also doesn't work when an
            // intent brings the task to foreground. The condition above is a temporary safety net.
            boolean handled = getSessionDataHolder().handleIntent(mIntent);
            if (handled) return true;
        }
        maybePrefetchDnsInBackground();

        // Strip EXTRA_CALLING_ACTIVITY_PACKAGE/EXTRA_LAUNCHED_FROM_PACKAGE if present on
        // the original intent so that it cannot be spoofed by CCT client apps.
        IntentUtils.safeRemoveExtra(mIntent, IntentHandler.EXTRA_CALLING_ACTIVITY_PACKAGE);
        IntentUtils.safeRemoveExtra(mIntent, IntentHandler.EXTRA_LAUNCHED_FROM_PACKAGE);

        Intent intent = new Intent(mIntent);
        String packageName = mActivity.getCallingPackage(); // from startActivityForResult
        String packageNameIdentitySharing = getCallingPackageIdentitySharing();
        if (packageName == null) packageName = packageNameIdentitySharing;
        if (packageName != null) {
            intent.putExtra(IntentHandler.EXTRA_CALLING_ACTIVITY_PACKAGE, packageName);
        }

        // Pass the package name obtained via identity sharing API separately from the one
        // obtained via startActivityForResult.
        boolean identityShared = packageNameIdentitySharing != null;
        if (identityShared) {
            intent.putExtra(IntentHandler.EXTRA_LAUNCHED_FROM_PACKAGE, packageNameIdentitySharing);
        }
        // Create and fire a launch intent.
        Intent launchIntent = createCustomTabActivityIntent(mActivity, intent);
        Uri extraReferrer = mActivity.getReferrer();
        if (extraReferrer != null) {
            launchIntent.putExtra(IntentHandler.EXTRA_ACTIVITY_REFERRER, extraReferrer.toString());
        }

        // Allow disk writes during startActivity() to avoid strict mode violations on some
        // Samsung devices, see https://crbug.com/796548.
        if (TwaSplashController.handleIntent(mActivity, launchIntent)) {
            return true;
        }

        mActivity.startActivity(launchIntent, null);
        RecordHistogram.recordBooleanHistogram("CustomTabs.IdentityShared", identityShared);
        return true;
    }

    private boolean launchWebApk() {
        // TODO(crbug.com/357902796): it may be possible to save 20ms or so by calling into
        // WebappLauncherActivity code directly instead of sending an intent.

        Intent webApkIntent = new Intent(WebappLauncherActivity.ACTION_START_WEBAPP);
        webApkIntent.setPackage(mActivity.getPackageName());

        webApkIntent.setFlags(mIntent.getFlags());

        Bundle copiedExtras = mIntent.getExtras();
        if (copiedExtras != null) {
            webApkIntent.putExtras(copiedExtras);
        }

        try {
            mActivity.startActivity(webApkIntent);
        } catch (ActivityNotFoundException e) {
            Log.w(TAG, "Unable to launch browser in WebAPK mode.");
            RecordHistogram.recordBooleanHistogram("WebApk.LaunchFromViewIntent", false);
            return false;
        }

        RecordHistogram.recordBooleanHistogram("WebApk.LaunchFromViewIntent", true);
        return true;
    }

    /**
     * Returns client package name obtained from {@link Activity#getLaunchedFromPackage()}. {@code
     * null} if the underlying OS doesn't support the feature.
     */
    private String getCallingPackageIdentitySharing() {
        return BuildCompat.isAtLeastU() ? mActivity.getLaunchedFromPackage() : null;
    }

    /** Handles launching a {@link ChromeTabbedActivity}. */
    @SuppressLint("InlinedApi")
    @SuppressWarnings("checkstyle:SystemExitCheck") // Allowed due to https://crbug.com/847921#c17.
    private @Action int dispatchToTabbedActivity() {
        maybePrefetchDnsInBackground();

        Intent newIntent = new Intent(mIntent);

        if (Intent.ACTION_VIEW.equals(newIntent.getAction())
                && !IntentHandler.wasIntentSenderChrome(newIntent)) {
            if (!chromeTabbedTaskExists()) {
                newIntent.putExtra(IntentHandler.EXTRA_STARTED_TABBED_CHROME_TASK, true);
            }
            if ((newIntent.getFlags() & Intent.FLAG_ACTIVITY_NEW_TASK) != 0) {
                // Adjacent launch flag, if present, already would have made the launcher activity
                // start on the adajcent screen in multi-window mode. Clear it on the new Intent for
                // the flag to take effect only once.
                newIntent.setFlags(newIntent.getFlags() & ~Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT);
            }
            RecordHistogram.recordBooleanHistogram(
                    "Android.Intent.HasNonSpoofablePackageName", hasNonSpoofablePackageName());
            boolean identityShared = getCallingPackageIdentitySharing() != null;
            RecordHistogram.recordBooleanHistogram("Android.Intent.IdentityShared", identityShared);
        }

        if (mActivity instanceof ChromeLauncherActivity) {
            newIntent.putExtra(IntentHandler.EXTRA_LAUNCHED_VIA_CHROME_LAUNCHER_ACTIVITY, true);
        }

        Uri extraReferrer = mActivity.getReferrer();
        if (extraReferrer != null) {
            newIntent.putExtra(IntentHandler.EXTRA_ACTIVITY_REFERRER, extraReferrer.toString());
        }

        String targetActivityClassName =
                MultiWindowUtils.getInstance()
                        .getTabbedActivityForIntent(newIntent, mActivity)
                        .getName();
        newIntent.setClassName(
                mActivity.getApplicationContext().getPackageName(), targetActivityClassName);
        newIntent.setFlags(
                Intent.FLAG_ACTIVITY_CLEAR_TOP
                        | Intent.FLAG_ACTIVITY_NEW_TASK
                        | Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS);

        // If the source of an intent containing FLAG_ACTIVITY_MULTIPLE_TASK is Chrome, retain the
        // flag to support multi-instance launch.
        if (IntentUtils.isTrustedIntentFromSelf(mIntent)
                && (mIntent.getFlags() & Intent.FLAG_ACTIVITY_MULTIPLE_TASK) != 0) {
            newIntent.setFlags(newIntent.getFlags() | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
        }

        Uri uri = newIntent.getData();
        boolean isContentScheme = false;
        if (uri != null && UrlConstants.CONTENT_SCHEME.equals(uri.getScheme())) {
            isContentScheme = true;
            newIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        }

        if (newIntent.getComponent().getClassName().equals(mActivity.getClass().getName())) {
            // We're trying to start activity that is already running - just continue.
            return Action.CONTINUE;
        }

        // This system call is often modified by OEMs and not actionable. http://crbug.com/619646.
        try {
            mActivity.startActivity(newIntent);
        } catch (SecurityException ex) {
            if (isContentScheme) {
                Toast.makeText(
                                mActivity,
                                org.chromium.chrome.R.string.external_app_restricted_access_error,
                                Toast.LENGTH_LONG)
                        .show();
            } else {
                throw ex;
            }
        }

        return Action.FINISH_ACTIVITY;
    }

    private boolean chromeTabbedTaskExists() {
        // Fast check for a running Chrome instance.
        for (Activity activity : ApplicationStatus.getRunningActivities()) {
            if (activity instanceof ChromeTabbedActivity) return true;
        }
        // Slightly slower check for an existing task (One IPC, usually ~2ms).
        try {
            Set<RecentTaskInfo> recentTaskInfos =
                    AndroidTaskUtils.getRecentTaskInfosMatchingComponentNames(
                            mActivity, ChromeTabbedActivity.TABBED_MODE_COMPONENT_NAMES);
            return !recentTaskInfos.isEmpty();
        } catch (SecurityException ex) {
            // If we can't query task status, assume a Chrome task exists so this doesn't
            // mistakenly lead to a Chrome task being removed.
            return true;
        }
    }

    private boolean hasNonSpoofablePackageName() {
        return !TextUtils.isEmpty(mActivity.getCallingPackage())
                || !TextUtils.isEmpty(getCallingPackageIdentitySharing());
    }

    private static boolean clearTopIntentsForCustomTabsEnabled(Intent intent) {
        // The new behavior is important for TWAs, but could potentially affect other clients.
        // For now we expose this risky change only to TWAs.
        return IntentUtils.safeGetBooleanExtra(
                intent, TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, false);
    }
}