blob: 8d54aaac6da80a08569151f46a9ccf35904bc656 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// 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;
import android.app.Notification;
import android.app.SearchManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.SystemClock;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.browser.customtabs.CustomTabsSessionToken;
import androidx.browser.customtabs.TrustedWebUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.StrictModeContext;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.app.video_tutorials.VideoTutorialShareHelper;
import org.chromium.chrome.browser.attribution_reporting.AttributionIntentHandler;
import org.chromium.chrome.browser.attribution_reporting.AttributionIntentHandlerFactory;
import org.chromium.chrome.browser.browserservices.SessionDataHolder;
import org.chromium.chrome.browser.browserservices.ui.splashscreen.trustedwebactivity.TwaSplashController;
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.firstrun.FirstRunFlowSequencer;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.instantapps.InstantAppsHandler;
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.translate.TranslateIntentHandler;
import org.chromium.chrome.browser.vr.VrModuleProvider;
import org.chromium.chrome.browser.webapps.WebappLauncherActivity;
import org.chromium.components.browser_ui.media.MediaNotificationUma;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.ui.widget.Toast;
import org.chromium.url.Origin;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* Dispatches incoming intents to the appropriate activity based on the current configuration and
* Intent fired.
*/
public class LaunchIntentDispatcher implements IntentHandler.IntentHandlerDelegate {
/**
* 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 = "ActivitiyDispatcher";
// Typically the number of tasks returned by getRecentTasks will be around 3 or less - the
// Chrome Launcher Activity, a Tabbed Activity task, and the home screen on older Android
// versions. However, theoretically this task list could be unbounded, so limit it to a number
// that won't cause Chrome to blow up in degenerate cases.
private static final int MAX_NUM_TASKS = 100;
private final Activity mActivity;
private Intent mIntent;
private final boolean mIsCustomTabIntent;
private final boolean mIsVrIntent;
private final AttributionIntentHandler mAttributionIntentHandler;
@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 (!dispatcher.mIsCustomTabIntent) return Action.CONTINUE;
dispatcher.launchCustomTabActivity();
return Action.FINISH_ACTIVITY;
}
private LaunchIntentDispatcher(Activity activity, Intent intent) {
mActivity = activity;
mIntent = IntentUtils.sanitizeIntent(intent);
mAttributionIntentHandler = AttributionIntentHandlerFactory.create();
// Needs to be called as early as possible, to accurately capture the
// time at which the intent was received.
if (mIntent != null && IntentHandler.getTimestampFromIntent(mIntent) == -1) {
IntentHandler.addTimestampToIntent(mIntent);
}
recordIntentMetrics();
mIsVrIntent = VrModuleProvider.getIntentDelegate().isVrIntent(mIntent);
mIsCustomTabIntent = isCustomTabIntent(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());
// Must come before processing other intents, as we may un-wrap |mIntent| to another type of
// Intent.
if (handleAppAttributionIntent()) return Action.FINISH_ACTIVITY;
int tabId = IntentHandler.getBringTabToFrontId(mIntent);
boolean incognito =
mIntent.getBooleanExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, false);
// Check if a web search Intent is being handled.
IntentHandler intentHandler = new IntentHandler(mActivity, this);
String url = IntentHandler.getUrlFromIntent(mIntent);
if (url == null && tabId == Tab.INVALID_TAB_ID && !incognito
&& intentHandler.handleWebSearchIntent(mIntent)) {
return Action.FINISH_ACTIVITY;
}
// Check if the URL is a video tutorial and needs to be handled in a video player.
if (VideoTutorialShareHelper.handleVideoTutorialURL(url)) {
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 launch an Instant App to handle the intent.
if (InstantAppsHandler.getInstance().handleIncomingIntent(
mActivity, mIntent, mIsCustomTabIntent, false)) {
return Action.FINISH_ACTIVITY;
}
// Check if we should push the user through First Run.
if (FirstRunFlowSequencer.launch(mActivity, mIntent, false /* requiresBroadcast */,
false /* preferLightweightFre */)) {
return Action.FINISH_ACTIVITY;
}
// Check if we should launch a Custom Tab.
if (mIsCustomTabIntent) {
launchCustomTabActivity();
return Action.FINISH_ACTIVITY;
}
return dispatchToTabbedActivity();
}
@Override
public void processWebSearchIntent(String query) {
Intent searchIntent = new Intent(Intent.ACTION_WEB_SEARCH);
searchIntent.putExtra(SearchManager.QUERY, query);
try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) {
int resolvers =
PackageManagerUtils
.queryIntentActivities(searchIntent, PackageManager.GET_RESOLVED_FILTER)
.size();
if (resolvers == 0) {
// 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);
} else {
mActivity.startActivity(searchIntent);
}
}
}
@Override
public void processTranslateTabIntent(
@Nullable String targetLanguageCode, @Nullable String expectedUrl) {
assert false;
}
@Override
public void processUrlViewIntent(String url, String referer, String headers,
@IntentHandler.TabOpenType int tabOpenType, String externalAppId,
int tabIdToBringToFront, boolean hasUserGesture, boolean isRendererInitiated,
@Nullable Origin initiatorOrigin, Intent intent) {
assert false;
}
/** 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);
}
}
}
/**
* Adds a token to TRANSLATE_TAB intents that we know were sent from a first party app.
*
* TRANSLATE_TAB requires a signature permission. We know that permission has been enforced (and
* thus comes from a first party application) if it was routed via the TranslateDispatcher
* activity-alias. In this case, add a token so IntentHandler knows the intent is from a first
* party app.
*/
private static void maybeAuthenticateFirstPartyTranslateIntent(Intent intent) {
if (intent != null && TranslateIntentHandler.ACTION_TRANSLATE_TAB.equals(intent.getAction())
&& TranslateIntentHandler.COMPONENT_TRANSLATE_DISPATCHER.equals(
intent.getComponent().getClassName())) {
IntentHandler.addTrustedIntentExtras(intent);
}
}
/**
* @return Whether the intent is for launching a Custom Tab.
*/
public static boolean isCustomTabIntent(Intent intent) {
if (intent == null) return false;
if (CustomTabsIntent.shouldAlwaysUseBrowserUI(intent)
|| !intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)) {
return false;
}
return IntentHandler.getUrlFromIntent(intent) != null;
}
/**
* 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());
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);
}
}
boolean isIntentSenderChrome = IntentHandler.wasIntentSenderChrome(intent);
// 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())) {
context.grantUriPermission(
context.getPackageName(), uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
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);
// 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);
}
// If the previous caller was not Chrome, but added EXTRA_IS_OPENED_BY_CHROME
// for malicious purpose, remove it. The new intent will be sent by Chrome, but was not
// sent by Chrome initially.
if (!isIntentSenderChrome) {
IntentUtils.safeRemoveExtra(
newIntent, CustomTabIntentDataProvider.EXTRA_IS_OPENED_BY_CHROME);
}
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.
*/
private void launchCustomTabActivity() {
CustomTabsConnection.getInstance().onHandledIntent(
CustomTabsSessionToken.getSessionTokenFromIntent(mIntent), mIntent);
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;
}
maybePrefetchDnsInBackground();
// Create and fire a launch intent.
Intent launchIntent = createCustomTabActivityIntent(mActivity, mIntent);
// Allow disk writes during startActivity() to avoid strict mode violations on some
// Samsung devices, see https://crbug.com/796548.
try (StrictModeContext ignored = StrictModeContext.allowDiskWrites()) {
if (TwaSplashController.handleIntent(mActivity, launchIntent)) {
return;
}
mActivity.startActivity(launchIntent, null);
}
}
/**
* Handles launching a {@link ChromeTabbedActivity}.
*/
@SuppressLint("InlinedApi")
@SuppressWarnings("checkstyle:SystemExitCheck") // Allowed due to https://crbug.com/847921#c17.
private @Action int dispatchToTabbedActivity() {
if (mIsVrIntent) {
for (Activity activity : ApplicationStatus.getRunningActivities()) {
if (activity instanceof ChromeTabbedActivity) {
if (VrModuleProvider.getDelegate().willChangeDensityInVr(
(ChromeActivity) activity)) {
// In the rare case that entering VR will trigger a density change (and
// hence an Activity recreation), just return to Daydream home and kill the
// process, as there's no good way to recreate without showing 2D UI
// in-headset.
mActivity.finish();
System.exit(0);
}
}
}
}
maybePrefetchDnsInBackground();
maybeAuthenticateFirstPartyTranslateIntent(mIntent);
Intent newIntent = new Intent(mIntent);
if (Intent.ACTION_VIEW.equals(newIntent.getAction())
&& !IntentHandler.wasIntentSenderChrome(newIntent)) {
long time = SystemClock.elapsedRealtime();
if (!chromeTabbedTaskExists()) {
newIntent.putExtra(IntentHandler.EXTRA_STARTED_TABBED_CHROME_TASK, true);
}
RecordHistogram.recordTimesHistogram("Startup.Android.ChromeTabbedTaskExistsTime",
SystemClock.elapsedRealtime() - time);
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
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);
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 (MultiWindowUtils.getInstance().shouldRunInLegacyMultiInstanceMode(mActivity, mIntent)) {
MultiWindowUtils.getInstance().makeLegacyMultiInstanceIntent(mActivity, newIntent);
}
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 {
Bundle options = mIsVrIntent
? VrModuleProvider.getIntentDelegate().getVrIntentOptions(mActivity)
: null;
mActivity.startActivity(newIntent, options);
} 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).
final ActivityManager activityManager =
(ActivityManager) mActivity.getSystemService(Context.ACTIVITY_SERVICE);
try {
boolean chromeTaskExists = false;
// getRecentTasks is deprecated, but still returns your app's tasks, and does so
// without needing an extra IPC for each task you want to get the info for. It also
// includes some known-safe tasks like the home screen on older Android versions, but
// that's fine for this purpose.
List<ActivityManager.RecentTaskInfo> tasks =
activityManager.getRecentTasks(MAX_NUM_TASKS, 0);
if (tasks != null) {
for (ActivityManager.RecentTaskInfo task : tasks) {
// Note that Android documentation lies, and TaskInfo#origActivity does not
// actually return the target of an alias, so we have to explicitly check
// for the target component of the base intent, which will have been set to
// the Activity that launched, in order to make this check more robust.
ComponentName component = task.baseIntent.getComponent();
if (component == null) continue;
if (ChromeTabbedActivity.isTabbedModeComponentName(component.getClassName())) {
return true;
}
}
}
} 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;
}
return false;
}
/**
* Records metrics gleaned from the Intent.
*/
private void recordIntentMetrics() {
@IntentHandler.ExternalAppId
int source = IntentHandler.determineExternalIntentSource(mIntent);
if (mIntent.getPackage() == null && source != IntentHandler.ExternalAppId.CHROME) {
int flagsOfInterest = Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
int maskedFlags = mIntent.getFlags() & flagsOfInterest;
RecordHistogram.recordSparseHistogram("Launch.IntentFlags", maskedFlags);
}
MediaNotificationUma.recordClickSource(mIntent);
}
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);
}
private boolean handleAppAttributionIntent() {
if (mAttributionIntentHandler.handleOuterAttributionIntent(mIntent)) return true;
Intent launchIntent = mAttributionIntentHandler.handleInnerAttributionIntent(mIntent);
if (launchIntent != null) mIntent = IntentUtils.sanitizeIntent(launchIntent);
return false;
}
}