blob: da96ce65194eea161cd20380de10278122248ef2 [file] [log] [blame]
// Copyright 2015 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.components.external_intents;
import android.Manifest.permission;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.StrictMode;
import android.os.SystemClock;
import android.provider.Browser;
import android.provider.Telephony;
import android.text.TextUtils;
import android.util.Pair;
import android.view.WindowManager.BadTokenException;
import android.webkit.MimeTypeMap;
import android.webkit.WebView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
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.PathUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.task.PostTask;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.components.embedder_support.util.UrlUtilitiesJni;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.NavigationController;
import org.chromium.content_public.browser.NavigationEntry;
import org.chromium.content_public.browser.UiThreadTaskTraits;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.content_public.common.Referrer;
import org.chromium.network.mojom.ReferrerPolicy;
import org.chromium.ui.UiUtils;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.PermissionCallback;
import org.chromium.url.URI;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
/**
* Logic related to the URL overriding/intercepting functionality.
* This feature supports conversion of certain navigations to Android Intents allowing
* applications like Youtube to direct users clicking on a http(s) link to their native app.
*/
public class ExternalNavigationHandler {
private static final String TAG = "UrlHandler";
// Enables debug logging on a local build.
private static final boolean DEBUG = false;
private static final String WTAI_URL_PREFIX = "wtai://wp/";
private static final String WTAI_MC_URL_PREFIX = "wtai://wp/mc;";
private static final String PLAY_PACKAGE_PARAM = "id";
private static final String PLAY_REFERRER_PARAM = "referrer";
private static final String PLAY_APP_PATH = "/store/apps/details";
private static final String PLAY_HOSTNAME = "play.google.com";
private static final String PDF_EXTENSION = "pdf";
private static final String PDF_VIEWER = "com.google.android.apps.docs";
private static final String PDF_MIME = "application/pdf";
private static final String PDF_SUFFIX = ".pdf";
/**
* Records package names of external applications in the system that could have handled this
* intent.
*/
public static final String EXTRA_EXTERNAL_NAV_PACKAGES = "org.chromium.chrome.browser.eenp";
@VisibleForTesting
public static final String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url";
// An extra that may be specified on an intent:// URL that contains an encoded value for the
// referrer field passed to the market:// URL in the case where the app is not present.
@VisibleForTesting
static final String EXTRA_MARKET_REFERRER = "market_referrer";
// A mask of flags that are safe for untrusted content to use when starting an Activity.
// This list is not exhaustive and flags not listed here are not necessarily unsafe.
@VisibleForTesting
static final int ALLOWED_INTENT_FLAGS = Intent.FLAG_EXCLUDE_STOPPED_PACKAGES
| Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP
| Intent.FLAG_ACTIVITY_MATCH_EXTERNAL | Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_MULTIPLE_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT
| Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS | Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT;
// These values are persisted in histograms. Please do not renumber. Append only.
@IntDef({AiaIntent.FALLBACK_USED, AiaIntent.SERP, AiaIntent.OTHER})
@Retention(RetentionPolicy.SOURCE)
private @interface AiaIntent {
int FALLBACK_USED = 0;
int SERP = 1;
int OTHER = 2;
int NUM_ENTRIES = 3;
}
// Standard Activity Actions, as defined by:
// https://developer.android.com/reference/android/content/Intent.html#standard-activity-actions
// These values are persisted in histograms. Please do not renumber.
@IntDef({StandardActions.MAIN, StandardActions.VIEW, StandardActions.ATTACH_DATA,
StandardActions.EDIT, StandardActions.PICK, StandardActions.CHOOSER,
StandardActions.GET_CONTENT, StandardActions.DIAL, StandardActions.CALL,
StandardActions.SEND, StandardActions.SENDTO, StandardActions.ANSWER,
StandardActions.INSERT, StandardActions.DELETE, StandardActions.RUN,
StandardActions.SYNC, StandardActions.PICK_ACTIVITY, StandardActions.SEARCH,
StandardActions.WEB_SEARCH, StandardActions.FACTORY_TEST, StandardActions.OTHER})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting
@interface StandardActions {
int MAIN = 0;
int VIEW = 1;
int ATTACH_DATA = 2;
int EDIT = 3;
int PICK = 4;
int CHOOSER = 5;
int GET_CONTENT = 6;
int DIAL = 7;
int CALL = 8;
int SEND = 9;
int SENDTO = 10;
int ANSWER = 11;
int INSERT = 12;
int DELETE = 13;
int RUN = 14;
int SYNC = 15;
int PICK_ACTIVITY = 16;
int SEARCH = 17;
int WEB_SEARCH = 18;
int FACTORY_TEST = 19;
int OTHER = 20;
int NUM_ENTRIES = 21;
}
@VisibleForTesting
static final String INTENT_ACTION_HISTOGRAM = "Android.Intent.OverrideUrlLoadingIntentAction";
// Helper class to return a boolean by reference.
private static class MutableBoolean {
private Boolean mValue = null;
public void set(boolean value) {
mValue = value;
}
public Boolean get() {
return mValue;
}
}
private final ExternalNavigationDelegate mDelegate;
/**
* Result types for checking if we should override URL loading.
* NOTE: this enum is used in UMA, do not reorder values. Changes should be append only.
* Values should be numerated from 0 and can't have gaps.
*/
@IntDef({OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT,
OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB,
OverrideUrlLoadingResult.OVERRIDE_WITH_ASYNC_ACTION,
OverrideUrlLoadingResult.NO_OVERRIDE})
@Retention(RetentionPolicy.SOURCE)
public @interface OverrideUrlLoadingResult {
/* We should override the URL loading and launch an intent. */
int OVERRIDE_WITH_EXTERNAL_INTENT = 0;
/* We should override the URL loading and clobber the current tab. */
int OVERRIDE_WITH_CLOBBERING_TAB = 1;
/* We should override the URL loading. The desired action will be determined
* asynchronously (e.g. by requiring user confirmation). */
int OVERRIDE_WITH_ASYNC_ACTION = 2;
/* We shouldn't override the URL loading. */
int NO_OVERRIDE = 3;
int NUM_ENTRIES = 4;
}
/**
* Constructs a new instance of {@link ExternalNavigationHandler}, using the injected
* {@link ExternalNavigationDelegate}.
*/
public ExternalNavigationHandler(ExternalNavigationDelegate delegate) {
mDelegate = delegate;
}
/**
* Determines whether the URL needs to be sent as an intent to the system,
* and sends it, if appropriate.
* @return Whether the URL generated an intent, caused a navigation in
* current tab, or wasn't handled at all.
*/
public @OverrideUrlLoadingResult int shouldOverrideUrlLoading(ExternalNavigationParams params) {
if (DEBUG) Log.i(TAG, "shouldOverrideUrlLoading called on " + params.getUrl());
Intent targetIntent;
// Perform generic parsing of the URI to turn it into an Intent.
try {
targetIntent = Intent.parseUri(params.getUrl(), Intent.URI_INTENT_SCHEME);
} catch (Exception ex) {
Log.w(TAG, "Bad URI %s", params.getUrl(), ex);
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
String browserFallbackUrl =
IntentUtils.safeGetStringExtra(targetIntent, EXTRA_BROWSER_FALLBACK_URL);
if (browserFallbackUrl != null
&& !UrlUtilities.isValidForIntentFallbackNavigation(browserFallbackUrl)) {
browserFallbackUrl = null;
}
// TODO(https://crbug.com/1096099): Refactor shouldOverrideUrlLoadingInternal, splitting it
// up to separate out the notions wanting to fire an external intent vs being able to.
MutableBoolean canLaunchExternalFallbackResult = new MutableBoolean();
long time = SystemClock.elapsedRealtime();
@OverrideUrlLoadingResult
int result = shouldOverrideUrlLoadingInternal(
params, targetIntent, browserFallbackUrl, canLaunchExternalFallbackResult);
assert canLaunchExternalFallbackResult.get() != null;
RecordHistogram.recordTimesHistogram(
"Android.StrictMode.OverrideUrlLoadingTime", SystemClock.elapsedRealtime() - time);
if (result != OverrideUrlLoadingResult.NO_OVERRIDE) {
int pageTransitionCore = params.getPageTransition() & PageTransition.CORE_MASK;
boolean isFormSubmit = pageTransitionCore == PageTransition.FORM_SUBMIT;
boolean isRedirectFromFormSubmit = isFormSubmit && params.isRedirect();
if (isRedirectFromFormSubmit) {
RecordHistogram.recordBooleanHistogram(
"Android.Intent.LaunchExternalAppFormSubmitHasUserGesture",
params.hasUserGesture());
}
} else if (result == OverrideUrlLoadingResult.NO_OVERRIDE && browserFallbackUrl != null
&& (params.getRedirectHandler() == null
// For instance, if this is a chained fallback URL, we ignore it.
|| !params.getRedirectHandler().shouldNotOverrideUrlLoading())) {
result = handleFallbackUrl(params, targetIntent, browserFallbackUrl,
canLaunchExternalFallbackResult.get());
}
if (DEBUG) printDebugShouldOverrideUrlLoadingResult(result);
return result;
}
private @OverrideUrlLoadingResult int handleFallbackUrl(ExternalNavigationParams params,
Intent targetIntent, String browserFallbackUrl, boolean canLaunchExternalFallback) {
if (mDelegate.isIntentToInstantApp(targetIntent)) {
RecordHistogram.recordEnumeratedHistogram("Android.InstantApps.DirectInstantAppsIntent",
AiaIntent.FALLBACK_USED, AiaIntent.NUM_ENTRIES);
}
if (canLaunchExternalFallback) {
if (shouldBlockAllExternalAppLaunches(params) || params.isIncognito()) {
throw new SecurityException("Context is not allowed to launch an external app.");
}
// Launch WebAPK if it can handle the URL.
try {
Intent intent = Intent.parseUri(browserFallbackUrl, Intent.URI_INTENT_SCHEME);
sanitizeQueryIntentActivitiesIntent(intent);
List<ResolveInfo> resolvingInfos = queryIntentActivities(intent);
if (!isAlreadyInTargetWebApk(resolvingInfos, params)
&& launchWebApkIfSoleIntentHandler(resolvingInfos, intent)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
} catch (Exception e) {
if (DEBUG) Log.i(TAG, "Could not parse fallback url as intent");
}
// If the fallback URL is a link to Play Store, send the user to Play Store app
// instead: crbug.com/638672.
Pair<String, String> appInfo = maybeGetPlayStoreAppIdAndReferrer(browserFallbackUrl);
if (appInfo != null) {
String marketReferrer = TextUtils.isEmpty(appInfo.second)
? ContextUtils.getApplicationContext().getPackageName()
: appInfo.second;
return sendIntentToMarket(appInfo.first, marketReferrer, params);
}
}
// For subframes, we don't support fallback url for now.
// http://crbug.com/364522.
if (!params.isMainFrame()) {
if (DEBUG) Log.i(TAG, "Don't support fallback url in subframes");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
// NOTE: any further redirection from fall-back URL should not override URL loading.
// Otherwise, it can be used in chain for fingerprinting multiple app installation
// status in one shot. In order to prevent this scenario, we notify redirection
// handler that redirection from the current navigation should stay in this app.
if (params.getRedirectHandler() != null) {
params.getRedirectHandler().setShouldNotOverrideUrlLoadingOnCurrentRedirectChain();
}
if (DEBUG) Log.i(TAG, "clobberCurrentTab called");
return clobberCurrentTab(browserFallbackUrl, params.getReferrerUrl());
}
private void printDebugShouldOverrideUrlLoadingResult(int result) {
String resultString;
switch (result) {
case OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT:
resultString = "OVERRIDE_WITH_EXTERNAL_INTENT";
break;
case OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB:
resultString = "OVERRIDE_WITH_CLOBBERING_TAB";
break;
case OverrideUrlLoadingResult.OVERRIDE_WITH_ASYNC_ACTION:
resultString = "OVERRIDE_WITH_ASYNC_ACTION";
break;
case OverrideUrlLoadingResult.NO_OVERRIDE: // Fall through.
default:
resultString = "NO_OVERRIDE";
break;
}
Log.i(TAG, "shouldOverrideUrlLoading result: " + resultString);
}
private boolean resolversSubsetOf(List<ResolveInfo> infos, List<ResolveInfo> container) {
if (container == null) return false;
HashSet<ComponentName> containerSet = new HashSet<>();
for (ResolveInfo info : container) {
containerSet.add(
new ComponentName(info.activityInfo.packageName, info.activityInfo.name));
}
for (ResolveInfo info : infos) {
if (!containerSet.contains(
new ComponentName(info.activityInfo.packageName, info.activityInfo.name))) {
return false;
}
}
return true;
}
/**
* https://crbug.com/1094442: Don't allow any external navigation on AUTO_SUBFRAME navigation
* (eg. initial ad frame navigation).
*/
private boolean blockExternalNavFromAutoSubframe(ExternalNavigationParams params) {
int pageTransitionCore = params.getPageTransition() & PageTransition.CORE_MASK;
if (pageTransitionCore == PageTransition.AUTO_SUBFRAME) {
if (DEBUG) Log.i(TAG, "Auto navigation in subframe");
return true;
}
return false;
}
/**
* http://crbug.com/441284 : Disallow firing external intent while the app is in the background.
*/
private boolean blockExternalNavWhileBackgrounded(ExternalNavigationParams params) {
if (params.isApplicationMustBeInForeground() && !mDelegate.isApplicationInForeground()) {
if (DEBUG) Log.i(TAG, "App is not in foreground");
return true;
}
return false;
}
/** http://crbug.com/464669 : Disallow firing external intent from background tab. */
private boolean blockExternalNavFromBackgroundTab(ExternalNavigationParams params) {
if (params.isBackgroundTabNavigation()) {
if (DEBUG) Log.i(TAG, "Navigation in background tab");
return true;
}
return false;
}
/**
* http://crbug.com/164194 . A navigation forwards or backwards should never trigger the intent
* picker.
*/
private boolean ignoreBackForwardNav(ExternalNavigationParams params) {
if ((params.getPageTransition() & PageTransition.FORWARD_BACK) != 0) {
if (DEBUG) Log.i(TAG, "Forward or back navigation");
return true;
}
return false;
}
/** http://crbug.com/605302 : Allow embedders to handle all pdf file downloads. */
private boolean isInternalPdfDownload(
boolean isExternalProtocol, ExternalNavigationParams params) {
if (!isExternalProtocol && isPdfDownload(params.getUrl())) {
if (DEBUG) Log.i(TAG, "PDF downloads are now handled internally");
return true;
}
return false;
}
/**
* If accessing a file URL, ensure that the user has granted the necessary file access
* to the app.
*/
private boolean startFileIntentIfNecessary(
ExternalNavigationParams params, Intent targetIntent) {
if (params.getUrl().startsWith(UrlConstants.FILE_URL_SHORT_PREFIX)
&& shouldRequestFileAccess(params.getUrl())) {
startFileIntent(targetIntent, params.getReferrerUrl(),
params.shouldCloseContentsOnOverrideUrlLoadingAndLaunchIntent());
if (DEBUG) Log.i(TAG, "Requesting filesystem access");
return true;
}
return false;
}
/**
* Trigger a UI affordance that will ask the user to grant file access. After the access
* has been granted or denied, continue loading the specified file URL.
*
* @param intent The intent to continue loading the file URL.
* @param referrerUrl The HTTP referrer URL.
* @param needsToCloseTab Whether this action should close the current tab.
*/
@VisibleForTesting
protected void startFileIntent(
final Intent intent, final String referrerUrl, final boolean needsToCloseTab) {
PermissionCallback permissionCallback = new PermissionCallback() {
@Override
public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED
&& mDelegate.hasValidTab()) {
loadUrlFromIntent(referrerUrl, intent.getDataString(), null, mDelegate,
needsToCloseTab, mDelegate.isIncognito());
} else {
// TODO(tedchoc): Show an indication to the user that the navigation failed
// instead of silently dropping it on the floor.
if (needsToCloseTab) {
// If the access was not granted, then close the tab if necessary.
mDelegate.closeTab();
}
}
}
};
if (!mDelegate.hasValidTab()) return;
mDelegate.getWindowAndroid().requestPermissions(
new String[] {permission.READ_EXTERNAL_STORAGE}, permissionCallback);
}
/**
* Clobber the current tab and try not to pass an intent when it should be handled internally
* so that we can deliver HTTP referrer information safely.
*
* @param url The new URL after clobbering the current tab.
* @param referrerUrl The HTTP referrer URL.
* @return OverrideUrlLoadingResult (if the tab has been clobbered, or we're launching an
* intent.)
*/
@VisibleForTesting
protected @OverrideUrlLoadingResult int clobberCurrentTab(String url, String referrerUrl) {
int transitionType = PageTransition.LINK;
final LoadUrlParams loadUrlParams = new LoadUrlParams(url, transitionType);
if (!TextUtils.isEmpty(referrerUrl)) {
Referrer referrer = new Referrer(referrerUrl, ReferrerPolicy.ALWAYS);
loadUrlParams.setReferrer(referrer);
}
if (mDelegate.hasValidTab()) {
// Loading URL will start a new navigation which cancels the current one
// that this clobbering is being done for. It leads to UAF. To avoid that,
// we're loading URL asynchronously. See https://crbug.com/732260.
PostTask.postTask(UiThreadTaskTraits.DEFAULT, new Runnable() {
@Override
public void run() {
mDelegate.loadUrlIfPossible(loadUrlParams);
}
});
return OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB;
} else {
assert false : "clobberCurrentTab was called with an empty tab.";
Uri uri = Uri.parse(url);
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
String packageName = ContextUtils.getApplicationContext().getPackageName();
intent.putExtra(Browser.EXTRA_APPLICATION_ID, packageName);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setPackage(packageName);
startActivity(intent, false, mDelegate);
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
}
private static void loadUrlWithReferrer(
final String url, final String referrerUrl, ExternalNavigationDelegate delegate) {
LoadUrlParams loadUrlParams = new LoadUrlParams(url, PageTransition.AUTO_TOPLEVEL);
if (!TextUtils.isEmpty(referrerUrl)) {
Referrer referrer = new Referrer(referrerUrl, ReferrerPolicy.ALWAYS);
loadUrlParams.setReferrer(referrer);
}
delegate.loadUrlIfPossible(loadUrlParams);
}
/**
* Loads the URL from an intent, either in the current tab or a new tab, falling back to the
* |alternateUrl| if the |primaryUrl| is unsupported.
*
* Handling is determined as follows:
*
* If the url scheme is not supported we do nothing.
* If the url can be loaded in the current tab then we load the url there.
* If the url can't be loaded in the current tab then we launch a new tab and load it there.
*
* @param referrerUrl The string containing the original url from where the intent was referred.
* @param primaryUrl The primary url to load.
* @param alternateUrl The fallback url to use if the primary url is null or invalid.
* @param delegate The delegate instance with this request is associated.
* @param launchIncognito Whether the url should be loaded in an incognito tab.
* @return true if the url is loaded in the current tab.
*/
public static boolean loadUrlFromIntent(String referrerUrl, String primaryUrl,
String alternateUrl, ExternalNavigationDelegate delegate, boolean needsToCloseTab,
boolean launchIncognito) {
// Check whether we should load this URL in the current tab or in a new tab.
if (!delegate.supportsCreatingNewTabs() && !delegate.canLoadUrlInCurrentTab()) return false;
boolean loadInNewTab = delegate.supportsCreatingNewTabs()
&& (!delegate.canLoadUrlInCurrentTab() || needsToCloseTab);
boolean isPrimaryUrlValid =
(primaryUrl != null) ? UrlUtilities.isAcceptedScheme(primaryUrl) : false;
boolean isAlternateUrlValid =
(alternateUrl != null) ? UrlUtilities.isAcceptedScheme(alternateUrl) : false;
if (!isPrimaryUrlValid && !isAlternateUrlValid) return false;
String url = (isPrimaryUrlValid) ? primaryUrl : alternateUrl;
if (loadInNewTab) {
delegate.loadUrlInNewTab(url, launchIncognito);
// Explicit request to close the tab.
if (needsToCloseTab) delegate.closeTab();
return false;
}
loadUrlWithReferrer(url, referrerUrl, delegate);
return true;
}
private boolean isTypedRedirectToExternalProtocol(
ExternalNavigationParams params, int pageTransitionCore, boolean isExternalProtocol) {
boolean isTyped = (pageTransitionCore == PageTransition.TYPED)
|| ((params.getPageTransition() & PageTransition.FROM_ADDRESS_BAR) != 0);
return isTyped && params.isRedirect() && isExternalProtocol;
}
/**
* http://crbug.com/659301: Don't stay in Chrome for Custom Tabs redirecting to Instant Apps.
*/
private boolean handleCCTRedirectsToInstantApps(ExternalNavigationParams params,
boolean isExternalProtocol, boolean incomingIntentRedirect) {
RedirectHandler handler = params.getRedirectHandler();
if (handler == null) return false;
if (handler.isFromCustomTabIntent() && !isExternalProtocol && incomingIntentRedirect
&& !handler.shouldNavigationTypeStayInApp()
&& mDelegate.maybeLaunchInstantApp(
params.getUrl(), params.getReferrerUrl(), true, isSerpReferrer())) {
if (DEBUG) {
Log.i(TAG, "Launching redirect to an instant app");
}
return true;
}
return false;
}
private boolean redirectShouldStayInApp(
ExternalNavigationParams params, boolean isExternalProtocol, Intent targetIntent) {
RedirectHandler handler = params.getRedirectHandler();
if (handler == null) return false;
boolean shouldStayInApp = handler.shouldStayInApp(
isExternalProtocol, mDelegate.isIntentForTrustedCallingApp(targetIntent));
if (shouldStayInApp || handler.shouldNotOverrideUrlLoading()) {
if (DEBUG) Log.i(TAG, "RedirectHandler decision");
return true;
}
return false;
}
/** Wrapper of check against the feature to support overriding for testing. */
@VisibleForTesting
boolean blockExternalFormRedirectsWithoutGesture() {
return ExternalIntentsFeatureList.isEnabled(
ExternalIntentsFeatureList.INTENT_BLOCK_EXTERNAL_FORM_REDIRECT_NO_GESTURE);
}
/**
* http://crbug.com/149218: We want to show the intent picker for ordinary links, providing
* the link is not an incoming intent from another application, unless it's a redirect.
*/
private boolean preferToShowIntentPicker(ExternalNavigationParams params,
int pageTransitionCore, boolean isExternalProtocol, boolean isFormSubmit,
boolean linkNotFromIntent, boolean incomingIntentRedirect) {
// http://crbug.com/169549 : If you type in a URL that then redirects in server side to a
// link that cannot be rendered by the browser, we want to show the intent picker.
if (isTypedRedirectToExternalProtocol(params, pageTransitionCore, isExternalProtocol)) {
return true;
}
// http://crbug.com/181186: We need to show the intent picker when we receive a redirect
// following a form submit.
boolean isRedirectFromFormSubmit = isFormSubmit && params.isRedirect();
if (!linkNotFromIntent && !incomingIntentRedirect && !isRedirectFromFormSubmit) {
if (DEBUG) Log.i(TAG, "Incoming intent (not a redirect)");
return false;
}
// http://crbug.com/839751: Require user gestures for form submits to external
// protocols.
// TODO(tedchoc): Turn this on by default once we verify this change does
// not break the world.
if (isRedirectFromFormSubmit && !incomingIntentRedirect && !params.hasUserGesture()
&& blockExternalFormRedirectsWithoutGesture()) {
if (DEBUG) {
Log.i(TAG,
"Incoming form intent attempting to redirect without "
+ "user gesture");
}
return false;
}
// http://crbug/331571 : Do not override a navigation started from user typing.
if (params.getRedirectHandler() != null
&& params.getRedirectHandler().isNavigationFromUserTyping()) {
if (DEBUG) Log.i(TAG, "Navigation from user typing");
return false;
}
return true;
}
/**
* http://crbug.com/159153: Don't override navigation from a chrome:* url to http or https. For
* example when clicking a link in bookmarks or most visited. When navigating from such a page,
* there is clear intent to complete the navigation in Chrome.
*/
private boolean isLinkFromChromeInternalPage(ExternalNavigationParams params) {
if (params.getReferrerUrl() == null) return false;
if (params.getReferrerUrl().startsWith(UrlConstants.CHROME_URL_PREFIX)
&& (params.getUrl().startsWith(UrlConstants.HTTP_URL_PREFIX)
|| params.getUrl().startsWith(UrlConstants.HTTPS_URL_PREFIX))) {
if (DEBUG) Log.i(TAG, "Link from an internal chrome:// page");
return true;
}
return false;
}
private boolean handleWtaiMcProtocol(ExternalNavigationParams params) {
if (!params.getUrl().startsWith(WTAI_MC_URL_PREFIX)) return false;
// wtai://wp/mc;number
// number=string(phone-number)
startActivity(new Intent(Intent.ACTION_VIEW,
Uri.parse(WebView.SCHEME_TEL
+ params.getUrl().substring(WTAI_MC_URL_PREFIX.length()))),
false, mDelegate);
if (DEBUG) Log.i(TAG, "wtai:// link handled");
RecordUserAction.record("Android.PhoneIntent");
return true;
}
private boolean isUnhandledWtaiProtocol(ExternalNavigationParams params) {
if (!params.getUrl().startsWith(WTAI_URL_PREFIX)) return false;
if (DEBUG) Log.i(TAG, "Unsupported wtai:// link");
return true;
}
/**
* The "about:", "chrome:", "chrome-native:", and "devtools:" schemes
* are internal to the browser; don't want these to be dispatched to other apps.
*/
private boolean hasInternalScheme(
ExternalNavigationParams params, Intent targetIntent, boolean hasIntentScheme) {
String url;
if (hasIntentScheme) {
// TODO(https://crbug.com/783819): When this function is converted to GURL, we should
// also call fixUpUrl on this user-provided URL as the fixed-up URL is what we would end
// up navigating to.
url = targetIntent.getDataString();
if (url == null) return false;
} else {
url = params.getUrl();
}
if (url.startsWith(ContentUrlConstants.ABOUT_SCHEME)
|| url.startsWith(UrlConstants.CHROME_URL_SHORT_PREFIX)
|| url.startsWith(UrlConstants.CHROME_NATIVE_URL_SHORT_PREFIX)
|| url.startsWith(UrlConstants.DEVTOOLS_URL_SHORT_PREFIX)) {
if (DEBUG) Log.i(TAG, "Navigating to a chrome-internal page");
return true;
}
return false;
}
/** The "content:" scheme is disabled in Clank. Do not try to start an activity. */
private boolean hasContentScheme(
ExternalNavigationParams params, Intent targetIntent, boolean hasIntentScheme) {
String url;
if (hasIntentScheme) {
url = targetIntent.getDataString();
if (url == null) return false;
} else {
url = params.getUrl();
}
if (!url.startsWith(UrlConstants.CONTENT_URL_SHORT_PREFIX)) return false;
if (DEBUG) Log.i(TAG, "Navigation to content: URL");
return true;
}
/**
* Intent URIs leads to creating intents that chrome would use for firing external navigations
* via Android. Android throws an exception [1] when an application exposes a file:// Uri to
* another app.
*
* This method checks if the |targetIntent| contains the file:// scheme in its data.
*
* [1]: https://developer.android.com/reference/android/os/FileUriExposedException
*/
private boolean hasFileSchemeInIntentURI(Intent targetIntent, boolean hasIntentScheme) {
// We are only concerned with targetIntent that was generated due to intent:// schemes only.
if (!hasIntentScheme) return false;
Uri data = targetIntent.getData();
if (data == null || data.getScheme() == null) return false;
if (data.getScheme().equalsIgnoreCase(UrlConstants.FILE_SCHEME)) {
if (DEBUG) Log.i(TAG, "Intent navigation to file: URI");
return true;
}
return false;
}
/**
* Special case - It makes no sense to use an external application for a YouTube
* pairing code URL, since these match the current tab with a device (Chromecast
* or similar) it is supposed to be controlling. Using a different application
* that isn't expecting this (in particular YouTube) doesn't work.
*/
private boolean isYoutubePairingCode(ExternalNavigationParams params) {
// TODO(https://crbug.com/1009539): Replace this regex with proper URI parsing.
if (params.getUrl().matches(".*youtube\\.com(\\/.*)?\\?(.+&)?pairingCode=[^&].+")) {
if (DEBUG) Log.i(TAG, "YouTube URL with a pairing code");
return true;
}
return false;
}
private boolean externalIntentRequestsDisabledForUrl(ExternalNavigationParams params) {
// TODO(changwan): check if we need to handle URL even when external intent is off.
if (CommandLine.getInstance().hasSwitch(
ExternalIntentsSwitches.DISABLE_EXTERNAL_INTENT_REQUESTS)) {
Log.w(TAG, "External intent handling is disabled by a command-line flag.");
return true;
}
if (mDelegate.shouldDisableExternalIntentRequestsForUrl(params.getUrl())) {
if (DEBUG) Log.i(TAG, "Delegate disables external intent requests for URL.");
return true;
}
return false;
}
/**
* If the intent can't be resolved, we should fall back to the browserFallbackUrl, or try to
* find the app on the market if no fallback is provided.
*/
private int handleUnresolvableIntent(
ExternalNavigationParams params, Intent targetIntent, String browserFallbackUrl) {
// Fallback URL will be handled by the caller of shouldOverrideUrlLoadingInternal.
if (browserFallbackUrl != null) return OverrideUrlLoadingResult.NO_OVERRIDE;
if (targetIntent.getPackage() != null) return handleWithMarketIntent(params, targetIntent);
if (DEBUG) Log.i(TAG, "Could not find an external activity to use");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
private @OverrideUrlLoadingResult int handleWithMarketIntent(
ExternalNavigationParams params, Intent intent) {
String marketReferrer = IntentUtils.safeGetStringExtra(intent, EXTRA_MARKET_REFERRER);
if (TextUtils.isEmpty(marketReferrer)) {
marketReferrer = ContextUtils.getApplicationContext().getPackageName();
}
return sendIntentToMarket(intent.getPackage(), marketReferrer, params);
}
private boolean maybeSetSmsPackage(Intent targetIntent) {
final Uri uri = targetIntent.getData();
if (targetIntent.getPackage() == null && uri != null
&& UrlConstants.SMS_SCHEME.equals(uri.getScheme())) {
List<ResolveInfo> resolvingInfos = queryIntentActivities(targetIntent);
targetIntent.setPackage(getDefaultSmsPackageName(resolvingInfos));
return true;
}
return false;
}
private void maybeRecordPhoneIntentMetrics(Intent targetIntent) {
final Uri uri = targetIntent.getData();
if (uri != null && UrlConstants.TEL_SCHEME.equals(uri.getScheme())
|| (Intent.ACTION_DIAL.equals(targetIntent.getAction()))
|| (Intent.ACTION_CALL.equals(targetIntent.getAction()))) {
RecordUserAction.record("Android.PhoneIntent");
}
}
/**
* In incognito mode, links that can be handled within the browser should just do so,
* without asking the user.
*/
private boolean shouldStayInIncognito(
ExternalNavigationParams params, boolean isExternalProtocol) {
if (params.isIncognito() && !isExternalProtocol) {
if (DEBUG) Log.i(TAG, "Stay incognito");
return true;
}
return false;
}
private boolean fallBackToHandlingWithInstantApp(ExternalNavigationParams params,
boolean incomingIntentRedirect, boolean linkNotFromIntent) {
if (incomingIntentRedirect
&& mDelegate.maybeLaunchInstantApp(
params.getUrl(), params.getReferrerUrl(), true, isSerpReferrer())) {
if (DEBUG) Log.i(TAG, "Launching instant Apps redirect");
return true;
} else if (linkNotFromIntent && !params.isIncognito()
&& mDelegate.maybeLaunchInstantApp(
params.getUrl(), params.getReferrerUrl(), false, isSerpReferrer())) {
if (DEBUG) Log.i(TAG, "Launching instant Apps link");
return true;
}
return false;
}
/**
* This is the catch-all path for any intent that the app can handle that doesn't have a
* specialized external app handling it.
*/
private @OverrideUrlLoadingResult int fallBackToHandlingInApp() {
if (DEBUG) Log.i(TAG, "No specialized handler for URL");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
/**
* Current URL has at least one specialized handler available. For navigations
* within the same host, keep the navigation inside the browser unless the set of
* available apps to handle the new navigation is different. http://crbug.com/463138
*/
private boolean shouldStayWithinHost(ExternalNavigationParams params, boolean isLink,
boolean isFormSubmit, List<ResolveInfo> resolvingInfos, boolean isExternalProtocol) {
if (isExternalProtocol) return false;
// TODO(https://crbug.com/1009539): Replace this host parsing with a UrlUtilities or GURL
// function call.
String lastCommittedUrl = getLastCommittedUrl();
String previousUriString =
lastCommittedUrl != null ? lastCommittedUrl : params.getReferrerUrl();
if (previousUriString == null || (!isLink && !isFormSubmit)) return false;
URI currentUri;
URI previousUri;
try {
currentUri = new URI(params.getUrl());
previousUri = new URI(previousUriString);
} catch (Exception e) {
return false;
}
if (currentUri == null || previousUri == null
|| !TextUtils.equals(currentUri.getHost(), previousUri.getHost())) {
return false;
}
Intent previousIntent;
try {
previousIntent = Intent.parseUri(previousUriString, Intent.URI_INTENT_SCHEME);
} catch (Exception e) {
return false;
}
if (previousIntent != null
&& resolversSubsetOf(resolvingInfos, queryIntentActivities(previousIntent))) {
if (DEBUG) Log.i(TAG, "Same host, no new resolvers");
return true;
}
return false;
}
/**
* For security reasons, we disable all intent:// URLs to Instant Apps that are not coming from
* SERP.
*/
private boolean preventDirectInstantAppsIntent(
boolean isDirectInstantAppsIntent, boolean shouldProxyForInstantApps) {
if (!isDirectInstantAppsIntent || shouldProxyForInstantApps) return false;
if (DEBUG) Log.i(TAG, "Intent URL to an Instant App");
RecordHistogram.recordEnumeratedHistogram("Android.InstantApps.DirectInstantAppsIntent",
AiaIntent.OTHER, AiaIntent.NUM_ENTRIES);
return true;
}
/**
* Prepare the intent to be sent. This function does not change the filtering for the intent,
* so the list if resolveInfos for the intent will be the same before and after this function.
*/
private void prepareExternalIntent(Intent targetIntent, ExternalNavigationParams params,
List<ResolveInfo> resolvingInfos, boolean shouldProxyForInstantApps) {
// Set the Browser application ID to us in case the user chooses this app
// as the app. This will make sure the link is opened in the same tab
// instead of making a new one in the case of Chrome.
targetIntent.putExtra(Browser.EXTRA_APPLICATION_ID,
ContextUtils.getApplicationContext().getPackageName());
if (params.isOpenInNewTab()) targetIntent.putExtra(Browser.EXTRA_CREATE_NEW_TAB, true);
targetIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// Ensure intents re-target potential caller activity when we run in CCT mode.
targetIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
mDelegate.maybeSetWindowId(targetIntent);
targetIntent.putExtra(EXTRA_EXTERNAL_NAV_PACKAGES, getSpecializedHandlers(resolvingInfos));
if (params.getReferrerUrl() != null) {
mDelegate.maybeSetPendingReferrer(targetIntent, params.getReferrerUrl());
}
if (params.isIncognito()) mDelegate.maybeSetPendingIncognitoUrl(targetIntent);
mDelegate.maybeAdjustInstantAppExtras(targetIntent, shouldProxyForInstantApps);
if (shouldProxyForInstantApps) {
RecordHistogram.recordEnumeratedHistogram("Android.InstantApps.DirectInstantAppsIntent",
AiaIntent.SERP, AiaIntent.NUM_ENTRIES);
}
mDelegate.maybeSetRequestMetadata(targetIntent, params.hasUserGesture(),
params.isRendererInitiated(), params.getInitiatorOrigin());
}
private @OverrideUrlLoadingResult int handleExternalIncognitoIntent(Intent targetIntent,
ExternalNavigationParams params, String browserFallbackUrl,
boolean shouldProxyForInstantApps) {
// This intent may leave this app. Warn the user that incognito does not carry over
// to external apps.
if (startIncognitoIntent(targetIntent, params.getReferrerUrl(), browserFallbackUrl,
params.shouldCloseContentsOnOverrideUrlLoadingAndLaunchIntent(),
shouldProxyForInstantApps)) {
if (DEBUG) Log.i(TAG, "Incognito navigation out");
return OverrideUrlLoadingResult.OVERRIDE_WITH_ASYNC_ACTION;
}
if (DEBUG) Log.i(TAG, "Failed to show incognito alert dialog.");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
/**
* Display a dialog warning the user that they may be leaving this app by starting this
* intent. Give the user the opportunity to cancel the action. And if it is canceled, a
* navigation will happen in this app. Catches BadTokenExceptions caused by showing the dialog
* on certain devices. (crbug.com/782602)
* @param intent The intent for external application that will be sent.
* @param referrerUrl The referrer for the current navigation.
* @param fallbackUrl The URL to load if the user doesn't proceed with external intent.
* @param needsToCloseTab Whether the current tab has to be closed after the intent is sent.
* @param proxy Whether we need to proxy the intent through AuthenticatedProxyActivity (this is
* used by Instant Apps intents.
* @return True if the function returned error free, false if it threw an exception.
*/
private boolean startIncognitoIntent(final Intent intent, final String referrerUrl,
final String fallbackUrl, final boolean needsToCloseTab, final boolean proxy) {
try {
return startIncognitoIntentInternal(
intent, referrerUrl, fallbackUrl, needsToCloseTab, proxy);
} catch (BadTokenException e) {
return false;
}
}
/**
* Internal implementation of startIncognitoIntent(), with all the same parameters.
*/
@VisibleForTesting
protected boolean startIncognitoIntentInternal(final Intent intent, final String referrerUrl,
final String fallbackUrl, final boolean needsToCloseTab, final boolean proxy) {
if (!mDelegate.hasValidTab()) return false;
Context context = mDelegate.getContext();
if (ContextUtils.activityFromContext(context) == null) return false;
new UiUtils.CompatibleAlertDialogBuilder(context, R.style.Theme_Chromium_AlertDialog)
.setTitle(R.string.external_app_leave_incognito_warning_title)
.setMessage(R.string.external_app_leave_incognito_warning)
.setPositiveButton(R.string.external_app_leave_incognito_leave,
new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
try {
startActivity(intent, proxy, mDelegate);
if (mDelegate.canCloseTabOnIncognitoIntentLaunch()
&& needsToCloseTab) {
mDelegate.closeTab();
}
} catch (ActivityNotFoundException e) {
// The activity that we thought was going to handle the intent
// no longer exists, so catch the exception and assume Chrome
// can handle it.
loadUrlFromIntent(referrerUrl, fallbackUrl,
intent.getDataString(), mDelegate, needsToCloseTab,
true);
}
}
})
.setNegativeButton(R.string.external_app_leave_incognito_stay,
new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
loadUrlFromIntent(referrerUrl, fallbackUrl, intent.getDataString(),
mDelegate, needsToCloseTab, true);
}
})
.setOnCancelListener(new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
loadUrlFromIntent(referrerUrl, fallbackUrl, intent.getDataString(),
mDelegate, needsToCloseTab, true);
}
})
.show();
return true;
}
/**
* If some third-party app launched this app with an intent, and the URL got redirected, and the
* user explicitly chose this app over other intent handlers, stay in the app unless there was a
* new intent handler after redirection or the app cannot handle it internally any more.
* Custom tabs are an exception to this rule, since at no point, the user sees an intent picker
* and "picking the Chrome app" is handled inside the support library.
*/
private boolean shouldKeepIntentRedirectInApp(ExternalNavigationParams params,
boolean incomingIntentRedirect, List<ResolveInfo> resolvingInfos,
boolean isExternalProtocol) {
if (params.getRedirectHandler() != null && incomingIntentRedirect && !isExternalProtocol
&& !params.getRedirectHandler().isFromCustomTabIntent()
&& !params.getRedirectHandler().hasNewResolver(resolvingInfos)) {
if (DEBUG) Log.i(TAG, "Custom tab redirect no handled");
return true;
}
return false;
}
/**
* Returns whether the activity belongs to a WebAPK and the URL is within the scope of the
* WebAPK. The WebAPK's main activity is a bouncer that redirects to the WebAPK Activity in
* Chrome. In order to avoid bouncing indefinitely, we should not override the navigation if we
* are currently showing the WebAPK (params#nativeClientPackageName()) that we will redirect to.
*/
private boolean isAlreadyInTargetWebApk(
List<ResolveInfo> resolveInfos, ExternalNavigationParams params) {
String currentName = params.nativeClientPackageName();
if (currentName == null) return false;
for (ResolveInfo resolveInfo : resolveInfos) {
ActivityInfo info = resolveInfo.activityInfo;
if (info != null && currentName.equals(info.packageName)) {
if (DEBUG) Log.i(TAG, "Already in WebAPK");
return true;
}
}
return false;
}
private boolean launchExternalIntent(Intent targetIntent, boolean shouldProxyForInstantApps) {
try {
if (!startActivityIfNeeded(targetIntent, shouldProxyForInstantApps)) {
if (DEBUG) Log.i(TAG, "The current Activity was the only targeted Activity.");
return false;
}
} catch (ActivityNotFoundException e) {
// The targeted app must have been uninstalled/disabled since we queried for Activities
// to handle this intent.
if (DEBUG) Log.i(TAG, "Activity not found.");
return false;
}
if (DEBUG) Log.i(TAG, "startActivityIfNeeded");
return true;
}
// This will handle external navigations only for intent meant for Autofill Assistant.
private boolean handleWithAutofillAssistant(
ExternalNavigationParams params, Intent targetIntent, String browserFallbackUrl) {
if (mDelegate.isIntentToAutofillAssistant(targetIntent)) {
if (mDelegate.handleWithAutofillAssistant(
params, targetIntent, browserFallbackUrl, isGoogleReferrer())) {
if (DEBUG) Log.i(TAG, "Handled with Autofill Assistant.");
} else {
if (DEBUG) Log.i(TAG, "Not handled with Autofill Assistant.");
}
return true;
}
return false;
}
// Check if we're navigating under conditions that should never launch an external app.
private boolean shouldBlockAllExternalAppLaunches(ExternalNavigationParams params) {
return blockExternalNavFromAutoSubframe(params) || blockExternalNavWhileBackgrounded(params)
|| blockExternalNavFromBackgroundTab(params) || ignoreBackForwardNav(params);
}
private @OverrideUrlLoadingResult int shouldOverrideUrlLoadingInternal(
ExternalNavigationParams params, Intent targetIntent,
@Nullable String browserFallbackUrl, MutableBoolean canLaunchExternalFallbackResult) {
sanitizeQueryIntentActivitiesIntent(targetIntent);
// Don't allow external fallback URLs by default.
canLaunchExternalFallbackResult.set(false);
if (shouldBlockAllExternalAppLaunches(params)) return OverrideUrlLoadingResult.NO_OVERRIDE;
if (handleWithAutofillAssistant(params, targetIntent, browserFallbackUrl)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
boolean isExternalProtocol = !UrlUtilities.isAcceptedScheme(params.getUrl());
if (isInternalPdfDownload(isExternalProtocol, params)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
// This check should happen for reloads, navigations, etc..., which is why
// it occurs before the subsequent blocks.
if (startFileIntentIfNecessary(params, targetIntent)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_ASYNC_ACTION;
}
// This should come after file intents, but before any returns of
// OVERRIDE_WITH_EXTERNAL_INTENT.
if (externalIntentRequestsDisabledForUrl(params)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
int pageTransitionCore = params.getPageTransition() & PageTransition.CORE_MASK;
boolean isLink = pageTransitionCore == PageTransition.LINK;
boolean isFormSubmit = pageTransitionCore == PageTransition.FORM_SUBMIT;
boolean isFromIntent = (params.getPageTransition() & PageTransition.FROM_API) != 0;
boolean linkNotFromIntent = isLink && !isFromIntent;
boolean isOnEffectiveIntentRedirect = params.getRedirectHandler() == null
? false
: params.getRedirectHandler().isOnEffectiveIntentRedirectChain();
// http://crbug.com/170925: We need to show the intent picker when we receive an intent from
// another app that 30x redirects to a YouTube/Google Maps/Play Store/Google+ URL etc.
boolean incomingIntentRedirect =
(isLink && isFromIntent && params.isRedirect()) || isOnEffectiveIntentRedirect;
if (handleCCTRedirectsToInstantApps(params, isExternalProtocol, incomingIntentRedirect)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
} else if (redirectShouldStayInApp(params, isExternalProtocol, targetIntent)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (!preferToShowIntentPicker(params, pageTransitionCore, isExternalProtocol, isFormSubmit,
linkNotFromIntent, incomingIntentRedirect)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (isLinkFromChromeInternalPage(params)) return OverrideUrlLoadingResult.NO_OVERRIDE;
if (handleWtaiMcProtocol(params)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
// TODO: handle other WTAI schemes.
if (isUnhandledWtaiProtocol(params)) return OverrideUrlLoadingResult.NO_OVERRIDE;
boolean hasIntentScheme = params.getUrl().startsWith(UrlConstants.INTENT_URL_SHORT_PREFIX)
|| params.getUrl().startsWith(UrlConstants.APP_INTENT_URL_SHORT_PREFIX);
if (hasInternalScheme(params, targetIntent, hasIntentScheme)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (hasContentScheme(params, targetIntent, hasIntentScheme)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (hasFileSchemeInIntentURI(targetIntent, hasIntentScheme)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (isYoutubePairingCode(params)) return OverrideUrlLoadingResult.NO_OVERRIDE;
if (shouldStayInIncognito(params, isExternalProtocol)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (!maybeSetSmsPackage(targetIntent)) maybeRecordPhoneIntentMetrics(targetIntent);
if (hasIntentScheme) recordIntentActionMetrics(targetIntent);
// From this point on, we have determined it is safe to launch an External App from a
// fallback URL, provided the user isn't in incognito.
if (!params.isIncognito()) canLaunchExternalFallbackResult.set(true);
Intent debugIntent = new Intent(targetIntent);
List<ResolveInfo> resolvingInfos = queryIntentActivities(targetIntent);
if (resolvingInfos.isEmpty()) {
return handleUnresolvableIntent(params, targetIntent, browserFallbackUrl);
}
if (browserFallbackUrl != null) targetIntent.removeExtra(EXTRA_BROWSER_FALLBACK_URL);
boolean hasSpecializedHandler = countSpecializedHandlers(resolvingInfos) > 0;
if (!isExternalProtocol && !hasSpecializedHandler) {
if (fallBackToHandlingWithInstantApp(
params, incomingIntentRedirect, linkNotFromIntent)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
return fallBackToHandlingInApp();
}
// From this point on we should only have intents that this app can't handle, or intents for
// apps with specialized handlers.
if (shouldStayWithinHost(
params, isLink, isFormSubmit, resolvingInfos, isExternalProtocol)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
boolean isDirectInstantAppsIntent =
isExternalProtocol && mDelegate.isIntentToInstantApp(targetIntent);
boolean shouldProxyForInstantApps = isDirectInstantAppsIntent && isSerpReferrer();
if (preventDirectInstantAppsIntent(isDirectInstantAppsIntent, shouldProxyForInstantApps)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
prepareExternalIntent(targetIntent, params, resolvingInfos, shouldProxyForInstantApps);
// As long as our intent resolution hasn't changed, resolvingInfos won't need to be
// re-computed as it won't have changed.
assert intentResolutionMatches(debugIntent, targetIntent);
if (params.isIncognito()) {
boolean intentTargetedToApp = mDelegate.willAppHandleIntent(targetIntent);
// The user is about to potentially leave the app, so we should ask whether they want to
// leave incognito or not.
if (!intentTargetedToApp) {
return handleExternalIncognitoIntent(
targetIntent, params, browserFallbackUrl, shouldProxyForInstantApps);
}
// The intent is staying in the app, so we can simply navigate to the intent's URL,
// while staying in incognito.
return mDelegate.handleIncognitoIntentTargetingSelf(
targetIntent, params.getReferrerUrl(), browserFallbackUrl);
}
if (shouldKeepIntentRedirectInApp(
params, incomingIntentRedirect, resolvingInfos, isExternalProtocol)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (isAlreadyInTargetWebApk(resolvingInfos, params)) {
return OverrideUrlLoadingResult.NO_OVERRIDE;
} else if (launchWebApkIfSoleIntentHandler(resolvingInfos, targetIntent)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
if (launchExternalIntent(targetIntent, shouldProxyForInstantApps)) {
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
/**
* Sanitize intent to be passed to {@link queryIntentActivities()}
* ensuring that web pages cannot bypass browser security.
*/
private void sanitizeQueryIntentActivitiesIntent(Intent intent) {
intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setComponent(null);
Intent selector = intent.getSelector();
if (selector != null) {
selector.addCategory(Intent.CATEGORY_BROWSABLE);
selector.setComponent(null);
}
}
/**
* @return OVERRIDE_WITH_EXTERNAL_INTENT when we successfully started market activity,
* NO_OVERRIDE otherwise.
*/
private @OverrideUrlLoadingResult int sendIntentToMarket(
String packageName, String marketReferrer, ExternalNavigationParams params) {
Uri marketUri =
new Uri.Builder()
.scheme("market")
.authority("details")
.appendQueryParameter(PLAY_PACKAGE_PARAM, packageName)
.appendQueryParameter(PLAY_REFERRER_PARAM, Uri.decode(marketReferrer))
.build();
Intent intent = new Intent(Intent.ACTION_VIEW, marketUri);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setPackage("com.android.vending");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (params.getReferrerUrl() != null) {
intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(params.getReferrerUrl()));
}
if (!deviceCanHandleIntent(intent)) {
// Exit early if the Play Store isn't available. (https://crbug.com/820709)
if (DEBUG) Log.i(TAG, "Play Store not installed.");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (params.isIncognito()) {
if (!startIncognitoIntent(intent, params.getReferrerUrl(), null,
params.shouldCloseContentsOnOverrideUrlLoadingAndLaunchIntent(), false)) {
if (DEBUG) Log.i(TAG, "Failed to show incognito alert dialog.");
return OverrideUrlLoadingResult.NO_OVERRIDE;
}
if (DEBUG) Log.i(TAG, "Incognito intent to Play Store.");
return OverrideUrlLoadingResult.OVERRIDE_WITH_ASYNC_ACTION;
} else {
startActivity(intent, false, mDelegate);
if (DEBUG) Log.i(TAG, "Intent to Play Store.");
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
}
/**
* If the given URL is to Google Play, extracts the package name and referrer tracking code
* from the {@param url} and returns as a Pair in that order. Otherwise returns null.
*/
private Pair<String, String> maybeGetPlayStoreAppIdAndReferrer(String url) {
Uri uri = Uri.parse(url);
if (PLAY_HOSTNAME.equals(uri.getHost()) && uri.getPath() != null
&& uri.getPath().startsWith(PLAY_APP_PATH)
&& !TextUtils.isEmpty(uri.getQueryParameter(PLAY_PACKAGE_PARAM))) {
return new Pair<String, String>(uri.getQueryParameter(PLAY_PACKAGE_PARAM),
uri.getQueryParameter(PLAY_REFERRER_PARAM));
}
return null;
}
/**
* @return Whether the |url| could be handled by an external application on the system.
*/
@VisibleForTesting
boolean canExternalAppHandleUrl(String url) {
if (url.startsWith(WTAI_MC_URL_PREFIX)) return true;
Intent intent;
try {
intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
} catch (URISyntaxException ex) {
// Ignore the error.
Log.w(TAG, "Bad URI %s", url, ex);
return false;
}
if (intent.getPackage() != null) return true;
List<ResolveInfo> resolvingInfos = queryIntentActivities(intent);
return resolvingInfos != null && !resolvingInfos.isEmpty();
}
/**
* Dispatch SMS intents to the default SMS application if applicable.
* Most SMS apps refuse to send SMS if not set as default SMS application.
*
* @param resolvingComponentNames The list of ComponentName that resolves the current intent.
*/
private String getDefaultSmsPackageName(List<ResolveInfo> resolvingComponentNames) {
String defaultSmsPackageName = getDefaultSmsPackageNameFromSystem();
if (defaultSmsPackageName == null) return null;
// Makes sure that the default SMS app actually resolves the intent.
for (ResolveInfo resolveInfo : resolvingComponentNames) {
if (defaultSmsPackageName.equals(resolveInfo.activityInfo.packageName)) {
return defaultSmsPackageName;
}
}
return null;
}
/**
* Launches WebAPK if the WebAPK is the sole non-browser handler for the given intent.
* @return Whether a WebAPK was launched.
*/
private boolean launchWebApkIfSoleIntentHandler(
List<ResolveInfo> resolvingInfos, Intent targetIntent) {
ArrayList<String> packages = getSpecializedHandlers(resolvingInfos);
if (packages.size() != 1 || !mDelegate.isValidWebApk(packages.get(0))) return false;
Intent webApkIntent = new Intent(targetIntent);
webApkIntent.setPackage(packages.get(0));
try {
startActivity(webApkIntent, false, mDelegate);
if (DEBUG) Log.i(TAG, "Launched WebAPK");
return true;
} catch (ActivityNotFoundException e) {
// The WebApk must have been uninstalled/disabled since we queried for Activities to
// handle this intent.
if (DEBUG) Log.i(TAG, "WebAPK launch failed");
return false;
}
}
/**
* Returns whether or not there's an activity available to handle the intent.
*/
private boolean deviceCanHandleIntent(Intent intent) {
List<ResolveInfo> resolveInfos = queryIntentActivities(intent);
return resolveInfos != null && !resolveInfos.isEmpty();
}
/**
* See {@link PackageManagerUtils#queryIntentActivities(Intent, int)}
*/
@NonNull
private List<ResolveInfo> queryIntentActivities(Intent intent) {
return PackageManagerUtils.queryIntentActivities(
intent, PackageManager.GET_RESOLVED_FILTER);
}
private static boolean intentResolutionMatches(Intent intent, Intent other) {
return intent.filterEquals(other)
&& (intent.getSelector() == other.getSelector()
|| intent.getSelector().filterEquals(other.getSelector()));
}
/**
* @return Whether the URL is a file download.
*/
@VisibleForTesting
boolean isPdfDownload(String url) {
String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
if (TextUtils.isEmpty(fileExtension)) return false;
return PDF_EXTENSION.equals(fileExtension);
}
private static boolean isPdfIntent(Intent intent) {
if (intent == null || intent.getData() == null) return false;
String filename = intent.getData().getLastPathSegment();
return (filename != null && filename.endsWith(PDF_SUFFIX))
|| PDF_MIME.equals(intent.getType());
}
/**
* Records the dispatching of an external intent.
*/
private static void recordExternalNavigationDispatched(Intent intent) {
ArrayList<String> specializedHandlers =
intent.getStringArrayListExtra(EXTRA_EXTERNAL_NAV_PACKAGES);
if (specializedHandlers != null && specializedHandlers.size() > 0) {
RecordUserAction.record("MobileExternalNavigationDispatched");
}
}
/**
* If the intent is for a pdf, resolves intent handlers to find the platform pdf viewer if
* it is available and force is for the provided |intent| so that the user doesn't need to
* choose it from Intent picker.
*
* @param intent Intent to open.
*/
private static void forcePdfViewerAsIntentHandlerIfNeeded(Intent intent) {
if (intent == null || !isPdfIntent(intent)) return;
resolveIntent(intent, true /* allowSelfOpen (ignored) */);
}
/**
* Retrieve the best activity for the given intent. If a default activity is provided,
* choose the default one. Otherwise, return the Intent picker if there are more than one
* capable activities. If the intent is pdf type, return the platform pdf viewer if
* it is available so user don't need to choose it from Intent picker.
*
* Note this function is slow on Android versions less than Lollipop.
*
* @param intent Intent to open.
* @param allowSelfOpen Whether chrome itself is allowed to open the intent.
* @return true if the intent can be resolved, or false otherwise.
*/
public static boolean resolveIntent(Intent intent, boolean allowSelfOpen) {
Context context = ContextUtils.getApplicationContext();
ResolveInfo info = PackageManagerUtils.resolveActivity(intent, 0);
if (info == null) return false;
final String packageName = context.getPackageName();
if (info.match != 0) {
// There is a default activity for this intent, use that.
return allowSelfOpen || !packageName.equals(info.activityInfo.packageName);
}
List<ResolveInfo> handlers = PackageManagerUtils.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY);
if (handlers == null || handlers.isEmpty()) return false;
boolean canSelfOpen = false;
boolean hasPdfViewer = false;
for (ResolveInfo resolveInfo : handlers) {
String pName = resolveInfo.activityInfo.packageName;
if (packageName.equals(pName)) {
canSelfOpen = true;
} else if (PDF_VIEWER.equals(pName)) {
if (isPdfIntent(intent)) {
intent.setClassName(pName, resolveInfo.activityInfo.name);
Uri referrer = new Uri.Builder()
.scheme(IntentUtils.ANDROID_APP_REFERRER_SCHEME)
.authority(packageName)
.build();
intent.putExtra(Intent.EXTRA_REFERRER, referrer);
hasPdfViewer = true;
break;
}
}
}
return !canSelfOpen || allowSelfOpen || hasPdfViewer;
}
/**
* Start an activity for the intent. Used for intents that must be handled externally.
* @param intent The intent we want to send.
* @param proxy Whether we need to proxy the intent through AuthenticatedProxyActivity (this is
* used by Instant Apps intents).
*/
public static void startActivity(
Intent intent, boolean proxy, ExternalNavigationDelegate delegate) {
try {
forcePdfViewerAsIntentHandlerIfNeeded(intent);
if (proxy) {
delegate.dispatchAuthenticatedIntent(intent);
} else {
// Start the activity via the current activity if possible, and otherwise as a new
// task from the application context.
Context context = ContextUtils.activityFromContext(delegate.getContext());
if (context == null) {
context = ContextUtils.getApplicationContext();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
context.startActivity(intent);
}
recordExternalNavigationDispatched(intent);
} catch (RuntimeException e) {
IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
}
delegate.didStartActivity(intent);
}
/**
* Start an activity for the intent. Used for intents that may be handled internally or
* externally.
* @param intent The intent we want to send.
* @param proxy Whether we need to proxy the intent through AuthenticatedProxyActivity (this is
* used by Instant Apps intents).
* @returns whether an activity was started for the intent.
*/
private boolean startActivityIfNeeded(Intent intent, boolean proxy) {
@ExternalNavigationDelegate.StartActivityIfNeededResult
int delegateResult = mDelegate.maybeHandleStartActivityIfNeeded(intent, proxy);
switch (delegateResult) {
case ExternalNavigationDelegate.StartActivityIfNeededResult.HANDLED_WITH_ACTIVITY_START:
return true;
case ExternalNavigationDelegate.StartActivityIfNeededResult
.HANDLED_WITHOUT_ACTIVITY_START:
return false;
case ExternalNavigationDelegate.StartActivityIfNeededResult.DID_NOT_HANDLE:
return startActivityIfNeededInternal(intent, proxy);
}
assert false;
return false;
}
/**
* Implementation of startActivityIfNeeded() that is used when the delegate does not handle the
* event.
*/
private boolean startActivityIfNeededInternal(Intent intent, boolean proxy) {
boolean activityWasLaunched;
// Only touches disk on Kitkat. See http://crbug.com/617725 for more context.
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
forcePdfViewerAsIntentHandlerIfNeeded(intent);
if (proxy) {
mDelegate.dispatchAuthenticatedIntent(intent);
activityWasLaunched = true;
} else {
Activity activity = ContextUtils.activityFromContext(mDelegate.getContext());
if (activity != null) {
activityWasLaunched = activity.startActivityIfNeeded(intent, -1);
} else {
activityWasLaunched = false;
}
}
if (activityWasLaunched) {
recordExternalNavigationDispatched(intent);
}
return activityWasLaunched;
} catch (SecurityException e) {
// https://crbug.com/808494: Handle the URL internally if dispatching to another
// application fails with a SecurityException. This happens due to malformed manifests
// in another app.
return false;
} catch (RuntimeException e) {
IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
return false;
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
}
/**
* Returns the number of specialized intent handlers in {@params infos}. Specialized intent
* handlers are intent handlers which handle only a few URLs (e.g. google maps or youtube).
*/
private int countSpecializedHandlers(List<ResolveInfo> infos) {
return getSpecializedHandlersWithFilter(
infos, null, mDelegate.handlesInstantAppLaunchingInternally())
.size();
}
/**
* Returns the subset of {@params infos} that are specialized intent handlers.
*/
private ArrayList<String> getSpecializedHandlers(List<ResolveInfo> infos) {
return getSpecializedHandlersWithFilter(
infos, null, mDelegate.handlesInstantAppLaunchingInternally());
}
private static boolean matchResolveInfoExceptWildCardHost(
ResolveInfo info, String filterPackageName) {
IntentFilter intentFilter = info.filter;
if (intentFilter == null) {
// Error on the side of classifying ResolveInfo as generic.
return false;
}
if (intentFilter.countDataAuthorities() == 0 && intentFilter.countDataPaths() == 0) {
// Don't count generic handlers.
return false;
}
boolean isWildCardHost = false;
Iterator<IntentFilter.AuthorityEntry> it = intentFilter.authoritiesIterator();
while (it != null && it.hasNext()) {
IntentFilter.AuthorityEntry entry = it.next();
if ("*".equals(entry.getHost())) {
isWildCardHost = true;
break;
}
}
if (isWildCardHost) {
return false;
}
if (!TextUtils.isEmpty(filterPackageName)
&& (info.activityInfo == null
|| !info.activityInfo.packageName.equals(filterPackageName))) {
return false;
}
return true;
}
public static ArrayList<String> getSpecializedHandlersWithFilter(List<ResolveInfo> infos,
String filterPackageName, boolean handlesInstantAppLaunchingInternally) {
ArrayList<String> result = new ArrayList<>();
if (infos == null) {
return result;
}
for (ResolveInfo info : infos) {
if (!matchResolveInfoExceptWildCardHost(info, filterPackageName)) {
continue;
}
if (info.activityInfo != null) {
if (handlesInstantAppLaunchingInternally
&& IntentUtils.isInstantAppResolveInfo(info)) {
// Don't add the Instant Apps launcher as a specialized handler if the embedder
// handles launching of Instant Apps itself.
continue;
}
result.add(info.activityInfo.packageName);
} else {
result.add("");
}
}
return result;
}
/**
* @return Default SMS application's package name at the system level. Null if there isn't any.
*/
@VisibleForTesting
protected String getDefaultSmsPackageNameFromSystem() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null;
return Telephony.Sms.getDefaultSmsPackage(ContextUtils.getApplicationContext());
}
/**
* @return The last committed URL from the WebContents.
*/
@VisibleForTesting
protected String getLastCommittedUrl() {
if (mDelegate.getWebContents() == null) return null;
return mDelegate.getWebContents().getLastCommittedUrl();
}
private void recordIntentActionMetrics(Intent intent) {
String action = intent.getAction();
@StandardActions
int standardAction;
if (TextUtils.isEmpty(action)) {
standardAction = StandardActions.VIEW;
} else {
standardAction = getStandardAction(action);
}
RecordHistogram.recordEnumeratedHistogram(
INTENT_ACTION_HISTOGRAM, standardAction, StandardActions.NUM_ENTRIES);
}
/**
* @param url The requested url.
* @return Whether we should block the navigation and request file access before proceeding.
*/
@VisibleForTesting
protected boolean shouldRequestFileAccess(String url) {
// If the tab is null, then do not attempt to prompt for access.
if (!mDelegate.hasValidTab()) return false;
// If the url points inside of Chromium's data directory, no permissions are necessary.
// This is required to prevent permission prompt when uses wants to access offline pages.
if (url.startsWith(UrlConstants.FILE_URL_PREFIX + PathUtils.getDataDirectory())) {
return false;
}
return !mDelegate.getWindowAndroid().hasPermission(permission.READ_EXTERNAL_STORAGE)
&& mDelegate.getWindowAndroid().canRequestPermission(
permission.READ_EXTERNAL_STORAGE);
}
@Nullable
private String getReferrerUrl() {
// TODO (thildebr): Investigate whether or not we can use getLastCommittedUrl() instead of
// the NavigationController.
if (!mDelegate.hasValidTab() || mDelegate.getWebContents() == null) return null;
NavigationController nController = mDelegate.getWebContents().getNavigationController();
int index = nController.getLastCommittedEntryIndex();
if (index == -1) return null;
NavigationEntry entry = nController.getEntryAtIndex(index);
if (entry == null) return null;
return entry.getUrl();
}
/**
* @return whether this navigation is from the search results page.
*/
@VisibleForTesting
protected boolean isSerpReferrer() {
String referrerUrl = getReferrerUrl();
if (referrerUrl == null) return false;
return UrlUtilitiesJni.get().isGoogleSearchUrl(referrerUrl);
}
private boolean isGoogleReferrer() {
String referrerUrl = getReferrerUrl();
if (referrerUrl == null) return false;
return UrlUtilitiesJni.get().isGoogleSubDomainUrl(referrerUrl);
}
private @StandardActions int getStandardAction(String action) {
switch (action) {
case Intent.ACTION_MAIN:
return StandardActions.MAIN;
case Intent.ACTION_VIEW:
return StandardActions.VIEW;
case Intent.ACTION_ATTACH_DATA:
return StandardActions.ATTACH_DATA;
case Intent.ACTION_EDIT:
return StandardActions.EDIT;
case Intent.ACTION_PICK:
return StandardActions.PICK;
case Intent.ACTION_CHOOSER:
return StandardActions.CHOOSER;
case Intent.ACTION_GET_CONTENT:
return StandardActions.GET_CONTENT;
case Intent.ACTION_DIAL:
return StandardActions.DIAL;
case Intent.ACTION_CALL:
return StandardActions.CALL;
case Intent.ACTION_SEND:
return StandardActions.SEND;
case Intent.ACTION_SENDTO:
return StandardActions.SENDTO;
case Intent.ACTION_ANSWER:
return StandardActions.ANSWER;
case Intent.ACTION_INSERT:
return StandardActions.INSERT;
case Intent.ACTION_DELETE:
return StandardActions.DELETE;
case Intent.ACTION_RUN:
return StandardActions.RUN;
case Intent.ACTION_SYNC:
return StandardActions.SYNC;
case Intent.ACTION_PICK_ACTIVITY:
return StandardActions.PICK_ACTIVITY;
case Intent.ACTION_SEARCH:
return StandardActions.SEARCH;
case Intent.ACTION_WEB_SEARCH:
return StandardActions.WEB_SEARCH;
case Intent.ACTION_FACTORY_TEST:
return StandardActions.FACTORY_TEST;
default:
return StandardActions.OTHER;
}
}
}