// Copyright 2015 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.components.external_intents;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.Intent.ShortcutIconResource;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.StrictMode;
import android.os.SystemClock;
import android.provider.Browser;
import android.provider.Telephony;
import android.text.TextUtils;
import android.util.AndroidRuntimeException;
import android.util.Pair;
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.RequiredCallback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.build.BuildConfig;
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.components.external_intents.ExternalNavigationParams.AsyncActionTakenParams;
import org.chromium.components.messages.DismissReason;
import org.chromium.components.messages.MessageBannerProperties;
import org.chromium.components.messages.MessageDispatcher;
import org.chromium.components.messages.MessageDispatcherProvider;
import org.chromium.components.messages.MessageIdentifier;
import org.chromium.components.messages.MessageScopeType;
import org.chromium.components.messages.PrimaryActionClickBehavior;
import org.chromium.components.webapk.lib.client.ChromeWebApkHostSignature;
import org.chromium.components.webapk.lib.client.WebApkValidator;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.ui.UiUtils;
import org.chromium.ui.base.MimeTypeUtils;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.permissions.PermissionCallback;
import org.chromium.url.GURL;
import org.chromium.url.Origin;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 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";
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";
@VisibleForTesting public static final String PLAY_APP_PACKAGE = "com.android.vending";
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";
/** Schemes used by web pages to start up the current browser without an explicit Intent. */
public static final String SELF_SCHEME_NAVIGATE_PREFIX = "://navigate?url=";
// 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;
@VisibleForTesting
static final String INSTANT_APP_SUPERVISOR_PKG = "com.google.android.instantapps.supervisor";
@VisibleForTesting
static final String[] INSTANT_APP_START_ACTIONS = {
"com.google.android.instantapps.START",
"com.google.android.instantapps.nmr1.INSTALL",
"com.google.android.instantapps.nmr1.VIEW"
};
// Helper class to return a boolean by reference.
private static class MutableBoolean {
private Boolean mValue;
public void set(boolean value) {
mValue = value;
}
public Boolean get() {
return mValue;
}
}
// A Supplier that only evaluates when needed then caches the value.
protected static class LazySupplier<T> implements Supplier<T> {
private T mValue;
private Supplier<T> mInnerSupplier;
public LazySupplier(Supplier<T> innerSupplier) {
assert innerSupplier != null : "innerSupplier cannot be null";
mInnerSupplier = innerSupplier;
}
@Nullable
@Override
public T get() {
if (mInnerSupplier != null) {
mValue = mInnerSupplier.get();
// Clear the inner supplier to record that we have evaluated and to free any
// references it may have held.
mInnerSupplier = null;
}
return mValue;
}
@Override
public boolean hasValue() {
return true;
}
}
private static class IntentBasedSupplier<T> extends LazySupplier<T> {
protected final Intent mIntent;
private Intent mIntentCopy;
public IntentBasedSupplier(Intent intent, Supplier<T> innerSupplier) {
super(innerSupplier);
mIntent = intent;
}
protected void assertIntentMatches() {
// If the intent filter changes the previously supplied result will no longer be valid.
if (BuildConfig.ENABLE_ASSERTS) {
if (mIntentCopy != null) {
assert intentResolutionMatches(mIntent, mIntentCopy);
} else {
mIntentCopy = new Intent(mIntent);
}
}
}
@Nullable
@Override
public T get() {
assertIntentMatches();
return super.get();
}
}
@VisibleForTesting
// A delegate responsible for showing a confirmation dialog in Incognito session, which upon
// positive user confirmation would result in navigations outside of Incognito.
class IncognitoDialogDelegate implements ModalDialogProperties.Controller {
private final Context mContext;
private final ExternalNavigationParams mParams;
private final Intent mIntent;
private final GURL mFallbackUrl;
// https://crbug.com/1412842, https://crbug.com/1474846: It seems dialogs sometimes end up
// with multiple results chosen.
private final AtomicBoolean mDialogResultChosen = new AtomicBoolean(false);
private PropertyModel mPropertyModel;
IncognitoDialogDelegate(
@NonNull Context context,
@NonNull ExternalNavigationParams params,
@NonNull Intent intent,
@NonNull GURL fallbackUrl) {
mContext = context;
mParams = params;
mIntent = intent;
mFallbackUrl = fallbackUrl;
}
@Override
public void onClick(
@NonNull PropertyModel model, @ModalDialogProperties.ButtonType int buttonType) {
if (ModalDialogProperties.ButtonType.POSITIVE == buttonType) {
if (mDialogResultChosen.get()) return;
mDialogResultChosen.set(true);
onUserDecidedWhetherToLaunchIncognitoIntent(true, mParams, mIntent, mFallbackUrl);
mModalDialogManager.dismissDialog(
mPropertyModel, DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
} else if (ModalDialogProperties.ButtonType.NEGATIVE == buttonType) {
if (mDialogResultChosen.get()) return;
mDialogResultChosen.set(true);
onUserDecidedWhetherToLaunchIncognitoIntent(false, mParams, mIntent, mFallbackUrl);
mModalDialogManager.dismissDialog(
mPropertyModel, DialogDismissalCause.NEGATIVE_BUTTON_CLICKED);
}
}
@Override
public void onDismiss(PropertyModel model, int dismissalCause) {
// This is already handled by the #onClick.
if (DialogDismissalCause.POSITIVE_BUTTON_CLICKED == dismissalCause
|| DialogDismissalCause.NEGATIVE_BUTTON_CLICKED == dismissalCause) {
return;
}
if (mDialogResultChosen.get()) return;
mDialogResultChosen.set(true);
onUserDecidedWhetherToLaunchIncognitoIntent(false, mParams, mIntent, mFallbackUrl);
mIncognitoDialogDelegate = null;
}
void showDialog() {
if (isShowing()) {
assert false : "Previous dialog is still being shown.";
return;
}
mPropertyModel =
new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
.with(ModalDialogProperties.CONTROLLER, this)
.with(
ModalDialogProperties.BUTTON_TAP_PROTECTION_PERIOD_MS,
UiUtils.PROMPT_INPUT_PROTECTION_SHORT_DELAY_MS)
.with(
ModalDialogProperties.TITLE,
mContext.getString(
R.string.external_app_leave_incognito_warning_title))
.with(
ModalDialogProperties.MESSAGE_PARAGRAPH_1,
mContext.getString(
R.string.external_app_leave_incognito_warning))
.with(
ModalDialogProperties.POSITIVE_BUTTON_TEXT,
mContext.getString(R.string.external_app_leave_incognito_leave))
.with(
ModalDialogProperties.NEGATIVE_BUTTON_TEXT,
mContext.getString(R.string.external_app_leave_incognito_stay))
.with(ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE, true)
.with(
ModalDialogProperties.BUTTON_STYLES,
ModalDialogProperties.ButtonStyles
.PRIMARY_OUTLINE_NEGATIVE_OUTLINE)
.build();
mModalDialogManager.showDialog(mPropertyModel, ModalDialogManager.ModalDialogType.TAB);
}
/** Browser initiated cancellation. */
void cancelDialog() {
mModalDialogManager.dismissDialog(mPropertyModel, DialogDismissalCause.NAVIGATE);
}
/** Browser initiated cancellation. */
void onNavigationStarted(long navigationId) {
if (navigationId == mParams.getNavigationId()) return;
// Cancel the dialog if a different navigation is started.
cancelDialog();
}
/** Browser initiated cancellation. */
void onNavigationFinished(long navigationId) {
if (navigationId == mParams.getNavigationId()) return;
// Cancel the dialog if a different navigation is finished.
cancelDialog();
}
boolean isShowing() {
return mPropertyModel != null && mModalDialogManager.isShowing();
}
@VisibleForTesting
void performClick(@ModalDialogProperties.ButtonType int buttonType) {
onClick(mPropertyModel, buttonType);
}
}
// Used to ensure we only call queryIntentActivities when we really need to.
protected class QueryIntentActivitiesSupplier extends IntentBasedSupplier<List<ResolveInfo>> {
// We need the query to include non-default intent filters, but should not return
// them for clients that don't explicitly need to check non-default filters.
private class QueryNonDefaultSupplier extends LazySupplier<List<ResolveInfo>> {
public QueryNonDefaultSupplier(Intent intent) {
super(
() ->
PackageManagerUtils.queryIntentActivities(
intent, PackageManager.GET_RESOLVED_FILTER));
}
}
final QueryNonDefaultSupplier mNonDefaultSupplier;
public QueryIntentActivitiesSupplier(Intent intent) {
super(intent, () -> queryIntentActivities(intent));
mNonDefaultSupplier = new QueryNonDefaultSupplier(intent);
}
public List<ResolveInfo> getIncludingNonDefaultResolveInfos() {
assertIntentMatches();
return mNonDefaultSupplier.get();
}
}
protected static class ResolveActivitySupplier extends IntentBasedSupplier<ResolveInfo> {
public ResolveActivitySupplier(Intent intent) {
super(
intent,
() ->
PackageManagerUtils.resolveActivity(
intent, PackageManager.MATCH_DEFAULT_ONLY));
}
}
/**
* 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.
* NOTE: NUM_ENTRIES must be added inside the IntDef{} to work around crbug.com/1300585. It
* should be removed from the IntDef{} if an alternate solution for that bug is found.
*/
@IntDef({
OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT,
OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB,
OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION,
OverrideUrlLoadingResultType.NO_OVERRIDE,
OverrideUrlLoadingResultType.NUM_ENTRIES
})
@Retention(RetentionPolicy.SOURCE)
public @interface OverrideUrlLoadingResultType {
/* We should override the URL loading and launch an intent. */
int OVERRIDE_WITH_EXTERNAL_INTENT = 0;
/* We should override the URL loading and perform a new navigation in the current tab. */
int OVERRIDE_WITH_NAVIGATE_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;
}
/** Types of async action that can be taken for a navigation. */
@IntDef({
NavigationChainResult.ALLOWED,
NavigationChainResult.REQUIRES_PROMPT,
NavigationChainResult.FOR_TRUSTED_CALLER
})
@Retention(RetentionPolicy.SOURCE)
public @interface NavigationChainResult {
/* The user has been presented with a consent dialog gating a browser navigation. */
int ALLOWED = 0;
/* The user has been presented with a consent dialog gating an intent launch. */
int REQUIRES_PROMPT = 1;
/* No async action has been taken. */
int FOR_TRUSTED_CALLER = 2;
}
/**
* Packages information about the result of a check of whether we should override URL loading.
*/
public static class OverrideUrlLoadingResult {
@OverrideUrlLoadingResultType int mResultType;
boolean mWasExternalFallbackUrlLaunch;
GURL mTargetUrl;
ExternalNavigationParams mExternalNavigationParams;
private OverrideUrlLoadingResult(@OverrideUrlLoadingResultType int resultType) {
this(resultType, false);
}
private OverrideUrlLoadingResult(GURL targetUrl, ExternalNavigationParams params) {
this(OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB, false);
mTargetUrl = targetUrl;
mExternalNavigationParams = params;
}
private OverrideUrlLoadingResult(
@OverrideUrlLoadingResultType int resultType,
boolean wasExternalFallbackUrlLaunch) {
assert (!wasExternalFallbackUrlLaunch
|| resultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT);
mResultType = resultType;
mWasExternalFallbackUrlLaunch = wasExternalFallbackUrlLaunch;
}
public @OverrideUrlLoadingResultType int getResultType() {
return mResultType;
}
public boolean wasExternalFallbackUrlLaunch() {
return mWasExternalFallbackUrlLaunch;
}
public GURL getTargetUrl() {
assert mResultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB;
return mTargetUrl;
}
public ExternalNavigationParams getExternalNavigationParams() {
assert mResultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB;
return mExternalNavigationParams;
}
/**
* Use this result when an asynchronous action needs to be carried out before deciding
* whether to block the external navigation.
*/
public static OverrideUrlLoadingResult forAsyncAction() {
return new OverrideUrlLoadingResult(
OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION, false);
}
/**
* Use this result when we would like to block an external navigation without prompting the
* user asking them whether would like to launch an app, or when the navigation does not
* target an app.
*/
public static OverrideUrlLoadingResult forNoOverride() {
return new OverrideUrlLoadingResult(OverrideUrlLoadingResultType.NO_OVERRIDE);
}
/**
* Use this result when the current external navigation should be blocked and a new
* navigation will be started in the Tab, replacing the previous one.
*/
public static OverrideUrlLoadingResult forNavigateTab(
GURL targetUrl, ExternalNavigationParams params) {
return new OverrideUrlLoadingResult(targetUrl, params);
}
/** Use this result when an external app has been launched as a result of the navigation. */
public static OverrideUrlLoadingResult forExternalIntent() {
return new OverrideUrlLoadingResult(
OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT);
}
/**
* Use this result when an external app has been launched as a result of using the fallback
* URL for an intent scheme navigation.
*/
public static OverrideUrlLoadingResult forExternalFallbackUrl() {
return new OverrideUrlLoadingResult(
OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT, true);
}
}
public static boolean sAllowIntentsToSelfForTesting;
private final ExternalNavigationDelegate mDelegate;
private final ModalDialogManager mModalDialogManager;
@VisibleForTesting protected IncognitoDialogDelegate mIncognitoDialogDelegate;
/**
* Constructs a new instance of {@link ExternalNavigationHandler}, using the injected {@link
* ExternalNavigationDelegate}.
*/
public ExternalNavigationHandler(ExternalNavigationDelegate delegate) {
mDelegate = delegate;
mModalDialogManager = mDelegate.getWindowAndroid().getModalDialogManager();
}
private static boolean debug() {
return ExternalIntentsFeatures.EXTERNAL_NAVIGATION_DEBUG_LOGS.isEnabled();
}
/**
* 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 shouldOverrideUrlLoading(ExternalNavigationParams params) {
if (debug()) Log.i(TAG, "shouldOverrideUrlLoading called on " + params.getUrl().getSpec());
Intent targetIntent;
boolean isIntentUrl = UrlUtilities.hasIntentScheme(params.getUrl());
// Perform generic parsing of the URI to turn it into an Intent.
if (isIntentUrl) {
try {
targetIntent = Intent.parseUri(params.getUrl().getSpec(), Intent.URI_INTENT_SCHEME);
} catch (Exception ex) {
Log.w(TAG, "Bad URI %s", params.getUrl().getSpec(), ex);
return OverrideUrlLoadingResult.forNoOverride();
}
} else if (isSupportedWtaiProtocol(params.getUrl())) {
targetIntent = parseWtaiMcProtocol(params.getUrl());
} else {
targetIntent = new Intent(Intent.ACTION_VIEW);
targetIntent.setData(Uri.parse(params.getUrl().getSpec()));
}
GURL browserFallbackUrl =
new GURL(IntentUtils.safeGetStringExtra(targetIntent, EXTRA_BROWSER_FALLBACK_URL));
if (!browserFallbackUrl.isValid() || !UrlUtilities.isHttpOrHttps(browserFallbackUrl)) {
browserFallbackUrl = GURL.emptyGURL();
}
targetIntent.removeExtra(EXTRA_BROWSER_FALLBACK_URL);
// TODO(crbug.com/40136041): 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 result =
shouldOverrideUrlLoadingInternal(
params, targetIntent, browserFallbackUrl, canLaunchExternalFallbackResult);
assert canLaunchExternalFallbackResult.get() != null;
RecordHistogram.recordTimesHistogram(
"Android.StrictMode.OverrideUrlLoadingTime", SystemClock.elapsedRealtime() - time);
if (result.getResultType() == OverrideUrlLoadingResultType.NO_OVERRIDE) {
result =
handleFallbackUrl(
params,
targetIntent,
browserFallbackUrl,
canLaunchExternalFallbackResult.get());
}
if (debug()) printDebugShouldOverrideUrlLoadingResultType(result);
if (result.getResultType() == OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION) {
params.onAsyncActionStarted();
}
return result;
}
private OverrideUrlLoadingResult handleFallbackUrl(
ExternalNavigationParams params,
Intent targetIntent,
GURL browserFallbackUrl,
boolean canLaunchExternalFallback) {
if (browserFallbackUrl.isEmpty()) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (canLaunchExternalFallback) {
if (shouldBlockAllExternalAppLaunches(params, isIncomingIntentRedirect(params))) {
throw new SecurityException("Context is not allowed to launch an external app.");
}
if (!params.isIncognito()) {
// Launch WebAPK if it can handle the URL.
try {
Intent intent =
Intent.parseUri(browserFallbackUrl.getSpec(), Intent.URI_INTENT_SCHEME);
sanitizeQueryIntentActivitiesIntent(intent);
QueryIntentActivitiesSupplier supplier =
new QueryIntentActivitiesSupplier(intent);
if (!isAlreadyInTargetWebApk(supplier, params)
&& launchWebApkIfSoleIntentHandler(supplier, intent, params)) {
return OverrideUrlLoadingResult.forExternalFallbackUrl();
}
} 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;
OverrideUrlLoadingResult result =
sendIntentToMarket(
appInfo.first, marketReferrer, params, browserFallbackUrl);
if (result.getResultType()
== OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT) {
result = OverrideUrlLoadingResult.forExternalFallbackUrl();
}
return result;
}
}
if (debug()) Log.i(TAG, "redirecting to fallback URL");
return OverrideUrlLoadingResult.forNavigateTab(browserFallbackUrl, params);
}
private void printDebugShouldOverrideUrlLoadingResultType(OverrideUrlLoadingResult result) {
String resultString;
switch (result.getResultType()) {
case OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT:
resultString = "OVERRIDE_WITH_EXTERNAL_INTENT";
break;
case OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB:
resultString = "OVERRIDE_WITH_NAVIGATE_TAB";
break;
case OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION:
resultString = "OVERRIDE_WITH_ASYNC_ACTION";
break;
case OverrideUrlLoadingResultType.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 subframe navigations
* without a user gesture (eg. initial ad frame navigation).
*/
private boolean shouldBlockSubframeAppLaunches(ExternalNavigationParams params) {
if (!params.isMainFrame() && !params.hasUserGesture()) {
if (debug()) Log.i(TAG, "Subframe navigation without user gesture.");
return true;
}
return false;
}
/** http://crbug.com/441284 : Disallow firing external intent while the app is in the background. */
private boolean blockExternalNavWhileBackgrounded(
ExternalNavigationParams params, boolean incomingIntentRedirect) {
// If the redirect is from an intent Chrome could still be transitioning to the foreground.
// Alternatively, the user may have sent Chrome to the background by this point, but for
// navigations started by another app that should still be safe.
if (incomingIntentRedirect) return false;
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, boolean incomingIntentRedirect) {
// See #blockExternalNavWhileBackgrounded - isBackgroundTabNavigation is effectively
// checking both that the tab is foreground, and the app is foreground, so we can skip it
// for intent launches for the same reason.
if (incomingIntentRedirect) return false;
if (params.isBackgroundTabNavigation()
&& !params.areIntentLaunchesAllowedInBackgroundTabs()) {
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 handleFileUrlPermissions(ExternalNavigationParams params) {
if (!params.getUrl().getScheme().equals(UrlConstants.FILE_SCHEME)) return false;
@MimeTypeUtils.Type int mimeType = MimeTypeUtils.getMimeTypeForUrl(params.getUrl());
String permissionNeeded = MimeTypeUtils.getPermissionNameForMimeType(mimeType);
if (permissionNeeded == null) return false;
if (!shouldRequestFileAccess(params.getUrl(), permissionNeeded)) return false;
requestFilePermissions(params, permissionNeeded);
if (debug()) Log.i(TAG, "Requesting filesystem access");
return true;
}
/**
* 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 params The {@link ExternalNavigationParams} for the navigation.
* @param permissionNeeded The name of the Android permission needed to access the file.
*/
@VisibleForTesting
protected void requestFilePermissions(
ExternalNavigationParams params, String permissionNeeded) {
PermissionCallback permissionCallback =
new PermissionCallback() {
@Override
public void onRequestPermissionsResult(
String[] permissions, int[] grantResults) {
if (grantResults.length == 0) return;
assert permissionNeeded.equals(permissions[0]);
if (grantResults[0] == PackageManager.PERMISSION_GRANTED
&& mDelegate.hasValidTab()) {
if (params.getRequiredAsyncActionTakenCallback() != null) {
params.getRequiredAsyncActionTakenCallback()
.onResult(
AsyncActionTakenParams.forNavigate(
params.getUrl(), params));
}
} else {
// TODO(tedchoc): Show an indication to the user that the navigation
// failed
// instead of silently dropping it on the floor.
if (params.getRequiredAsyncActionTakenCallback() != null) {
params.getRequiredAsyncActionTakenCallback()
.onResult(AsyncActionTakenParams.forNoAction());
}
}
}
};
if (!mDelegate.hasValidTab()) return;
mDelegate
.getWindowAndroid()
.requestPermissions(new String[] {permissionNeeded}, permissionCallback);
}
// https://crbug.com/1232514: On Android S, since WebAPKs aren't verified apps they are
// never launched as the result of a suitable Intent, the user's default browser will be
// opened instead. As a temporary solution, have Chrome launch the WebAPK.
//
// Note that we also need to query for non-default handlers as WebApks being non-default
// Web Intent handlers is the cause of the issue.
private boolean intentMatchesNonDefaultWebApk(
ExternalNavigationParams params, QueryIntentActivitiesSupplier resolvingInfos) {
if (params.isFromIntent() && mDelegate.shouldLaunchWebApksOnInitialIntent()) {
String packageName = pickWebApkIfSoleIntentHandler(params, resolvingInfos);
if (packageName != null) {
if (debug()) Log.i(TAG, "Matches possibly non-default WebApk");
return true;
}
}
return false;
}
/**
* 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().getScheme().equals(UrlConstants.CHROME_SCHEME)
&& UrlUtilities.isHttpOrHttps(params.getUrl())) {
if (debug()) Log.i(TAG, "Link from an internal chrome:// page");
return true;
}
return false;
}
private static boolean isSupportedWtaiProtocol(GURL url) {
return url.getSpec().startsWith(WTAI_MC_URL_PREFIX);
}
private static Intent parseWtaiMcProtocol(GURL url) {
assert isSupportedWtaiProtocol(url);
// wtai://wp/mc;number
// number=string(phone-number)
String phoneNumber = url.getSpec().substring(WTAI_MC_URL_PREFIX.length());
if (debug()) Log.i(TAG, "wtai:// link handled");
RecordUserAction.record("Android.PhoneIntent");
return new Intent(Intent.ACTION_VIEW, Uri.parse(WebView.SCHEME_TEL + phoneNumber));
}
private static boolean isUnhandledWtaiProtocol(ExternalNavigationParams params) {
if (!params.getUrl().getSpec().startsWith(WTAI_URL_PREFIX)) return false;
if (isSupportedWtaiProtocol(params.getUrl())) 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(GURL targetUrl, Intent targetIntent) {
if (isInternalScheme(targetUrl.getScheme())) {
if (debug()) Log.i(TAG, "Navigating to a chrome-internal page");
return true;
}
if (UrlUtilities.hasIntentScheme(targetUrl)
&& targetIntent.getData() != null
&& isInternalScheme(targetIntent.getData().getScheme())) {
if (debug()) Log.i(TAG, "Navigating to a chrome-internal page");
return true;
}
return false;
}
private static boolean isInternalScheme(String scheme) {
if (TextUtils.isEmpty(scheme)) return false;
return scheme.equals(ContentUrlConstants.ABOUT_SCHEME)
|| scheme.equals(UrlConstants.CHROME_SCHEME)
|| scheme.equals(UrlConstants.CHROME_NATIVE_SCHEME)
|| scheme.equals(UrlConstants.DEVTOOLS_SCHEME);
}
/**
* The "content:" scheme is disabled in Clank. Do not try to start an external activity, or
* load the URL in-browser.
*/
private boolean hasContentScheme(GURL targetUrl, Intent targetIntent) {
boolean hasContentScheme = false;
if (UrlUtilities.hasIntentScheme(targetUrl) && targetIntent.getData() != null) {
hasContentScheme =
UrlConstants.CONTENT_SCHEME.equals(targetIntent.getData().getScheme());
} else {
hasContentScheme = UrlConstants.CONTENT_SCHEME.equals(targetUrl.getScheme());
}
if (debug() && hasContentScheme) Log.i(TAG, "Navigation to content: URL");
return hasContentScheme;
}
/**
* 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(GURL targetUrl, Intent targetIntent) {
// We are only concerned with targetIntent that was generated due to intent:// schemes only.
if (!UrlUtilities.hasIntentScheme(targetUrl)) 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.
*/
@VisibleForTesting
protected boolean isYoutubePairingCode(GURL url) {
if (url.domainIs("youtube.com")
&& !TextUtils.isEmpty(UrlUtilities.getValueForKeyInQuery(url, "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;
}
/**
* @return whether something along the navigation chain prevents the current navigation from
* leaving Chrome.
*/
private @NavigationChainResult int navigationChainBlocksExternalNavigation(
ExternalNavigationParams params,
Intent targetIntent,
QueryIntentActivitiesSupplier resolvingInfos,
boolean isExternalProtocol,
boolean shouldReturnAsResult) {
RedirectHandler handler = params.getRedirectHandler();
RedirectHandler.InitialNavigationState initialState = handler.getInitialNavigationState();
// If a navigation chain has used the history API to go back/forward external navigation is
// probably not expected or desirable.
if (handler.navigationChainUsedBackOrForward()) {
if (debug()) Log.i(TAG, "Navigation chain used back or forward.");
return NavigationChainResult.REQUIRES_PROMPT;
}
// Used to prevent things like chaining fallback URLs.
if (handler.shouldNotOverrideUrlLoading()) {
if (debug()) Log.i(TAG, "Navigation chain has blocked app launching.");
return NavigationChainResult.REQUIRES_PROMPT;
}
// Tab Restores should definitely not launch apps, and refreshes launching apps would
// probably not be expected or desirable.
if (initialState.isFromReload) {
if (debug()) Log.i(TAG, "Navigation chain is from a tab restore or refresh.");
return NavigationChainResult.REQUIRES_PROMPT;
}
// TODO(crbug.com/40232652): We only need to check isFromTyping because WebLayer's
// implementation of disabling intent processing is broken and doesn't actually disable
// intent processing, but to align with current weblayer behavior the first navigation has
// to be blocked even if the weblayer delegate tells us not to block embedder initiated
// navigations. See
// https://source.chromium.org/chromium/chromium/src/+/main:weblayer/browser/navigation_controller_impl.cc;drc=88d7b2e74349cbf8b3e15b61cc0663d65f9d1873;l=220
if (!initialState.isRendererInitiated
&& !initialState.isFromIntent
&& (mDelegate.shouldEmbedderInitiatedNavigationsStayInBrowser()
|| initialState.isFromTyping)) {
if (debug()) Log.i(TAG, "Browser initiated navigation chain.");
return NavigationChainResult.REQUIRES_PROMPT;
}
// If the intent targets the calling app, we can bypass the gesture requirements and any
// signals from the initial intent that suggested the intent wanted to stay in Chrome.
// This also takes effect if the url is overridden for Activity#setResult.
if (mDelegate.isForTrustedCallingApp(resolvingInfos) || shouldReturnAsResult) {
return NavigationChainResult.FOR_TRUSTED_CALLER;
}
// See RedirectHandler#NAVIGATION_CHAIN_TIMEOUT_MILLIS for details. We don't want an
// unattended page to redirect to an app.
if (handler.isNavigationChainExpired()) {
if (debug()) {
Log.i(
TAG,
"Navigation chain expired "
+ "(a page waited more than %d seconds to redirect).",
RedirectHandler.NAVIGATION_CHAIN_TIMEOUT_MILLIS);
}
return NavigationChainResult.REQUIRES_PROMPT;
}
// If an intent targeted Chrome explicitly, we assume the app wanted to launch Chrome and
// not another app.
if (handler.intentPrefersToStayInChrome() && !isExternalProtocol) {
if (debug()) Log.i(TAG, "Launching intent explicitly targeted the browser.");
return NavigationChainResult.REQUIRES_PROMPT;
}
// Ensure the navigation was started with a user gesture so that inactive pages can't launch
// apps unexpectedly, unless we trust the calling app for a CCT/TWA.
if (initialState.isRendererInitiated && !initialState.hasUserGesture) {
if (isExternalProtocol) handler.maybeLogExternalRedirectBlockedWithMissingGesture();
if (debug()) Log.i(TAG, "Navigation chain started without a gesture.");
return NavigationChainResult.REQUIRES_PROMPT;
}
return NavigationChainResult.ALLOWED;
}
/**
* If a site is submitting a form, it most likely wants to submit that data to a server rather
* than launch an app.
*/
private boolean isDirectFormSubmit(
ExternalNavigationParams params, boolean isExternalProtocol) {
// If a form is submitting to an external protocol, don't block it.
if (isExternalProtocol) return false;
// Redirects off of form submits need to be able to launch apps.
if (params.isRedirect()) return false;
int pageTransitionCore = params.getPageTransition() & PageTransition.CORE_MASK;
boolean isFormSubmit = pageTransitionCore == PageTransition.FORM_SUBMIT;
if (isFormSubmit) {
if (debug()) Log.i(TAG, "Direct form submission, not a redirect");
return true;
}
return false;
}
/*
* The initial navigation from an Intent should always stay in the browser as the sending app,
* or the user must have chosen the browser to do the navigation.
*/
private boolean isDirectIntentNavigation(
ExternalNavigationParams params,
boolean intentMatchesNonDefaultWebApk,
boolean incomingIntentRedirect) {
// S+ workaround for WebAPKs not being able to handle Intents.
if (intentMatchesNonDefaultWebApk) return false;
if (!params.isFromIntent()) return false;
// Redirects off of intents are still allowed to launch apps (eg. URL shorteners).
if (incomingIntentRedirect) return false;
if (debug()) Log.i(TAG, "Initial intent navigation.");
return true;
}
/**
* 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 OverrideUrlLoadingResult handleUnresolvableIntent(
ExternalNavigationParams params,
Intent targetIntent,
GURL browserFallbackUrl,
@NavigationChainResult int navigationChainResult,
boolean isExternalProtocol) {
if (isExternalProtocol) {
// https://crbug.com/330555390. In order to avoid a fingerprinting vector, if an
// external protocol fails to launch an app due to the app not being installed, future
// navigations on the same redirect chain should also stay in Chrome.
params.getRedirectHandler().setShouldNotOverrideUrlLoadingOnCurrentRedirectChain();
}
if (navigationChainResult != NavigationChainResult.ALLOWED) {
return OverrideUrlLoadingResult.forNoOverride();
}
// Fallback URL will be handled by the caller of shouldOverrideUrlLoadingInternal.
if (!browserFallbackUrl.isEmpty()) return OverrideUrlLoadingResult.forNoOverride();
if (targetIntent.getPackage() != null) {
return handleWithMarketIntent(params, targetIntent);
}
if (debug()) Log.i(TAG, "Could not find an external activity to use");
return OverrideUrlLoadingResult.forNoOverride();
}
private OverrideUrlLoadingResult 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, GURL.emptyGURL());
}
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;
}
/**
* 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 fallBackToHandlingInApp() {
if (debug()) Log.i(TAG, "No specialized handler for URL");
return OverrideUrlLoadingResult.forNoOverride();
}
/**
* If a navigation is targeting the current browser, just load the URL in the browser to avoid
* exposing capabilities only intended for other apps on the device to the web (and weird things
* like websites launching CCTs).
*/
private boolean isNavigationToSelf(
ExternalNavigationParams params,
QueryIntentActivitiesSupplier resolvingInfos,
ResolveActivitySupplier resolveActivity,
boolean isExternalProtocol) {
if (sAllowIntentsToSelfForTesting) return false;
if (!ExternalIntentsFeatures.BLOCK_INTENTS_TO_SELF.isEnabled() && params.isMainFrame()) {
return false;
}
if (!isExternalProtocol) return false;
if (!resolveInfoContainsSelf(resolvingInfos.get())) return false;
if (resolveActivity.get() == null) return false;
ActivityInfo info = resolveActivity.get().activityInfo;
if (info != null && mDelegate.getContext().getPackageName().equals(info.packageName)) {
if (debug()) Log.i(TAG, "Navigation to self.");
return true;
}
// We don't want the user seeing the chooser and choosing the browser, but resolving to
// another app is fine.
if (resolvesToChooser(resolveActivity.get(), resolvingInfos)) {
if (debug()) Log.i(TAG, "Navigation to chooser including self.");
return true;
}
return false;
}
/**
* Returns true if the intent is an insecure intent targeting browsers or browser-like apps
* (excluding the embedding app).
*/
private boolean isInsecureIntentToOtherBrowser(
Intent targetIntent,
QueryIntentActivitiesSupplier resolveInfos,
boolean isIntentWithSupportedProtocol,
ResolveActivitySupplier resolveActivity,
boolean intentHasExtras) {
// If an intent has Extras or a data URI it may be used to launch arbitrary URIs in insecure
// browsers.
if (!intentHasExtras
&& (targetIntent.getData() == null || targetIntent.getData().equals(Uri.EMPTY))) {
return false;
}
if (targetIntent.getPackage() != null
&& targetIntent
.getPackage()
.equals(ContextUtils.getApplicationContext().getPackageName())) {
return false;
}
String selfPackageName = mDelegate.getContext().getPackageName();
boolean matchesOtherPackage = false;
for (ResolveInfo resolveInfo : resolveInfos.get()) {
ActivityInfo info = resolveInfo.activityInfo;
if (info == null || !selfPackageName.equals(info.packageName)) {
matchesOtherPackage = true;
break;
}
}
if (!matchesOtherPackage) return false;
// Querying for browser packages will catch Intents that use custom URL schemes like
// googlechrome:// or are otherwise not considered by Android to be Web intents but can
// still load arbitrary URLs in a browser.
Set<String> browserPackages = getInstalledBrowserPackages();
boolean matchesBrowser = false;
for (ResolveInfo resolveInfo : resolveInfos.get()) {
ActivityInfo info = resolveInfo.activityInfo;
if (info != null && browserPackages.contains(info.packageName)) {
matchesBrowser = true;
break;
}
}
if (!matchesBrowser) return false;
if (resolveActivity.get().activityInfo == null) return false;
// If the intent resolves to a non-browser even through a browser is included in
// queryIntentActivities, it's not really targeting a browser.
return browserPackages.contains(resolveActivity.get().activityInfo.packageName);
}
private static Set<String> getInstalledBrowserPackages() {
List<ResolveInfo> browsers = PackageManagerUtils.queryAllWebBrowsersInfo();
Set<String> packageNames = new HashSet<>();
for (ResolveInfo browser : browsers) {
if (browser.activityInfo == null) continue;
packageNames.add(browser.activityInfo.packageName);
}
return packageNames;
}
/**
* 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,
List<ResolveInfo> resolvingInfos,
boolean isExternalProtocol) {
if (isExternalProtocol || !params.isRendererInitiated()) return false;
GURL previousUrl = getLastCommittedUrl();
if (previousUrl == null) previousUrl = params.getReferrerUrl();
if (previousUrl.isEmpty()) return false;
GURL currentUrl = params.getUrl();
if (!TextUtils.equals(currentUrl.getHost(), previousUrl.getHost())) {
return false;
}
Intent previousIntent = new Intent(Intent.ACTION_VIEW);
previousIntent.setData(Uri.parse(previousUrl.getSpec()));
if (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. */
private boolean preventDirectInstantAppsIntent(Intent intent) {
if (isIntentToInstantApp(intent)) {
if (debug()) Log.i(TAG, "Intent URL to an Instant App");
return true;
}
return false;
}
/**
* https://crbug.com/1066555. A re-navigation can make it look like the current tab is
* performing a navigation when it's actually a background tab doing the navigation.
*/
private boolean isHiddenCrossFrameRenavigation(ExternalNavigationParams params) {
if (!ExternalIntentsFeatures.BLOCK_FRAME_RENAVIGATIONS.isEnabled()) return false;
if (params.getRedirectHandler().navigationChainPerformedHiddenCrossFrameNavigation()) {
if (debug()) Log.i(TAG, "Navigation chain used cross-frame re-navigation.");
return true;
}
if (params.isInitialNavigationInFrame() || !params.isHiddenCrossFrameNavigation()) {
return false;
}
// Server redirects can be seen as cross frame to the initial navigation in the frame, but
// are still controlled by the site in the frame.
if (params.isRedirect()) return false;
if (debug()) Log.i(TAG, "Cross-frame re-navigation.");
params.getRedirectHandler().setPerformedHiddenCrossFrameNavigation();
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) {
// 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().isEmpty()) {
mDelegate.maybeSetPendingReferrer(targetIntent, params.getReferrerUrl());
}
if (params.isIncognito()) mDelegate.maybeSetPendingIncognitoUrl(targetIntent);
mDelegate.maybeSetRequestMetadata(
targetIntent, params.hasUserGesture(), params.isRendererInitiated());
}
private OverrideUrlLoadingResult handleExternalIncognitoIntent(
Intent targetIntent, ExternalNavigationParams params, GURL browserFallbackUrl) {
// This intent may leave this app. Warn the user that incognito does not carry over
// to external apps.
if (startIncognitoIntent(params, targetIntent, browserFallbackUrl)) {
if (debug()) Log.i(TAG, "Incognito navigation out");
return OverrideUrlLoadingResult.forAsyncAction();
}
if (debug()) Log.i(TAG, "Failed to show incognito alert dialog.");
return OverrideUrlLoadingResult.forNoOverride();
}
/**
* 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 params {@link ExternalNavigationParams}
* @param intent The intent for external application that will be sent.
* @param fallbackUrl The URL to load if the user doesn't proceed with external intent.
* @return True if the function returned error free, false if it threw an exception.
*/
private boolean startIncognitoIntent(
ExternalNavigationParams params, Intent intent, GURL fallbackUrl) {
Context context = mDelegate.getContext();
if (!canLaunchIncognitoIntent(intent, context)) return false;
if (mDelegate.hasCustomLeavingIncognitoDialog()) {
mDelegate.presentLeavingIncognitoModalDialog(
shouldLaunch -> {
onUserDecidedWhetherToLaunchIncognitoIntent(
shouldLaunch.booleanValue(), params, intent, fallbackUrl);
});
return true;
}
mIncognitoDialogDelegate = showLeavingIncognitoDialog(context, params, intent, fallbackUrl);
mIncognitoDialogDelegate.showDialog();
return true;
}
@VisibleForTesting
protected boolean canLaunchIncognitoIntent(Intent intent, Context context) {
if (!mDelegate.hasValidTab()) return false;
if (ContextUtils.activityFromContext(context) == null) return false;
return true;
}
@VisibleForTesting
protected IncognitoDialogDelegate showLeavingIncognitoDialog(
final Context context,
final ExternalNavigationParams params,
final Intent intent,
final GURL fallbackUrl) {
return new IncognitoDialogDelegate(context, params, intent, fallbackUrl);
}
private void onUserDecidedWhetherToLaunchIncognitoIntent(
final boolean shouldLaunch,
final ExternalNavigationParams params,
final Intent intent,
final GURL fallbackUrl) {
if (shouldLaunch) {
try {
startActivity(intent, params);
if (params.getRequiredAsyncActionTakenCallback() != null) {
params.getRequiredAsyncActionTakenCallback()
.onResult(
AsyncActionTakenParams.forExternalIntentLaunched(
mDelegate.canCloseTabOnIncognitoIntentLaunch(),
params));
}
return;
} catch (ActivityNotFoundException e) {
// The activity that we thought was going to handle the intent
// no longer exists, so catch the exception and fall through to handling the
// fallback URL.
}
}
OverrideUrlLoadingResult result = handleFallbackUrl(params, intent, fallbackUrl, false);
if (params.getRequiredAsyncActionTakenCallback() != null) {
if (result.getResultType() == OverrideUrlLoadingResultType.NO_OVERRIDE) {
// There was no fallback URL and we can't handle the URL the intent was targeting.
// In this case we'll return to the last committed URL.
params.getRequiredAsyncActionTakenCallback()
.onResult(AsyncActionTakenParams.forNoAction());
} else {
assert result.getResultType()
== OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB;
params.getRequiredAsyncActionTakenCallback()
.onResult(
AsyncActionTakenParams.forNavigate(result.getTargetUrl(), params));
}
}
}
/**
* If another app, or the user, chose to launch this app for an intent, we should keep that
* navigation within this app through redirects until it resolves to a new app or external
* protocol given this app was intentionally chosen. Custom tabs always explicitly target the
* browser and this issue is handled elsewhere through
* {@link RedirectHandler#intentPrefersToStayInChrome()}.
*
* Usually this covers cases like https://www.youtube.com/ redirecting to
* https://m.youtube.com/. Note that this isn't covered by {@link #shouldStayWithinHost()} as
* for intent navigation there is no previously committed URL.
*/
private boolean shouldKeepIntentRedirectInApp(
ExternalNavigationParams params,
boolean incomingIntentRedirect,
List<ResolveInfo> resolvingInfos,
boolean isExternalProtocol) {
if (incomingIntentRedirect
&& !isExternalProtocol
&& !params.getRedirectHandler().isFromCustomTabIntent()
&& !params.getRedirectHandler()
.hasNewResolver(
resolvingInfos, (Intent intent) -> queryIntentActivities(intent))) {
if (debug()) Log.i(TAG, "Intent navigation with no new handlers.");
return true;
}
return false;
}
/**
* @param packageName The package to check.
* @return Whether the package is a valid WebAPK package.
*/
@VisibleForTesting
protected boolean isValidWebApk(String packageName) {
// Ensure that WebApkValidator is initialized (note: this method is a no-op after the first
// time that it is invoked).
WebApkValidator.init(
ChromeWebApkHostSignature.EXPECTED_SIGNATURE, ChromeWebApkHostSignature.PUBLIC_KEY);
return WebApkValidator.isValidWebApk(ContextUtils.getApplicationContext(), packageName);
}
/**
* 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(
QueryIntentActivitiesSupplier resolvingInfos, ExternalNavigationParams params) {
String currentName = params.nativeClientPackageName();
if (currentName == null) return false;
for (ResolveInfo resolveInfo : getResolveInfosForWebApks(params, resolvingInfos)) {
ActivityInfo info = resolveInfo.activityInfo;
if (info != null && currentName.equals(info.packageName)) {
if (debug()) Log.i(TAG, "Already in WebAPK");
return true;
}
}
return false;
}
// Check if we're navigating under conditions that should never launch an external app,
// regardless of which URL we're navigating to.
private boolean shouldBlockAllExternalAppLaunches(
ExternalNavigationParams params, boolean incomingIntentRedirect) {
return shouldBlockSubframeAppLaunches(params)
|| blockExternalNavWhileBackgrounded(params, incomingIntentRedirect)
|| blockExternalNavFromBackgroundTab(params, incomingIntentRedirect)
|| ignoreBackForwardNav(params);
}
private OverrideUrlLoadingResult shouldOverrideUrlLoadingInternal(
ExternalNavigationParams params,
Intent targetIntent,
GURL browserFallbackUrl,
MutableBoolean canLaunchExternalFallbackResult) {
sanitizeQueryIntentActivitiesIntent(targetIntent);
// Any subsequent navigations should cancel the existing dialog.
if (mIncognitoDialogDelegate != null && mIncognitoDialogDelegate.isShowing()) {
mIncognitoDialogDelegate.cancelDialog();
}
// Don't allow external fallback URLs by default.
canLaunchExternalFallbackResult.set(false);
if (!maybeSetSmsPackage(targetIntent)) maybeRecordPhoneIntentMetrics(targetIntent);
// 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 = isIncomingIntentRedirect(params);
boolean isExternalProtocol = !UrlUtilities.isAcceptedScheme(params.getUrl());
GURL intentTargetUrl = new GURL(targetIntent.getDataString());
// Unpack schemes targeting the current browser.
String selfScheme = mDelegate.getSelfScheme();
if (selfScheme != null && intentTargetUrl.getScheme().equals(selfScheme)) {
intentTargetUrl =
new GURL(getUrlFromSelfSchemeUrl(selfScheme, intentTargetUrl.getSpec()));
}
// intent: URLs are considered an external protocol, but may still contain a Data URI that
// this app does support, and may still end up launching this app.
boolean isIntentWithSupportedProtocol =
UrlUtilities.hasIntentScheme(params.getUrl())
&& UrlUtilities.isAcceptedScheme(intentTargetUrl);
// Needs to be checked first as a failure for this reason is persisted through the
// navigation chain, and other failures should not cause this check to be skipped.
if (isHiddenCrossFrameRenavigation(params)) return OverrideUrlLoadingResult.forNoOverride();
if (shouldBlockAllExternalAppLaunches(params, incomingIntentRedirect)) {
return OverrideUrlLoadingResult.forNoOverride();
}
// This check should happen for reloads, navigations, etc..., which is why
// it occurs before the subsequent blocks.
if (handleFileUrlPermissions(params)) {
return OverrideUrlLoadingResult.forAsyncAction();
}
// This should come after file intents, but before any returns of
// OVERRIDE_WITH_EXTERNAL_INTENT.
if (externalIntentRequestsDisabledForUrl(params)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (isLinkFromChromeInternalPage(params)) return OverrideUrlLoadingResult.forNoOverride();
if (isDirectFormSubmit(params, isExternalProtocol)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (hasInternalScheme(params.getUrl(), targetIntent)
|| hasContentScheme(params.getUrl(), targetIntent)
|| hasFileSchemeInIntentURI(params.getUrl(), targetIntent)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (isYoutubePairingCode(params.getUrl())) return OverrideUrlLoadingResult.forNoOverride();
if (shouldStayInIncognito(params, isExternalProtocol)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (isInternalPdfDownload(isExternalProtocol, params)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (isUnhandledWtaiProtocol(params)) return OverrideUrlLoadingResult.forNoOverride();
if (preventDirectInstantAppsIntent(targetIntent)) {
return OverrideUrlLoadingResult.forNoOverride();
}
QueryIntentActivitiesSupplier resolvingInfos =
new QueryIntentActivitiesSupplier(targetIntent);
boolean intentMatchesNonDefaultWebApk =
intentMatchesNonDefaultWebApk(params, resolvingInfos);
if (isDirectIntentNavigation(
params, intentMatchesNonDefaultWebApk, incomingIntentRedirect)) {
return OverrideUrlLoadingResult.forNoOverride();
}
boolean shouldReturnAsResult = mDelegate.shouldReturnAsActivityResult(intentTargetUrl);
@NavigationChainResult
int navigationChainResult =
navigationChainBlocksExternalNavigation(
params,
targetIntent,
resolvingInfos,
isExternalProtocol,
shouldReturnAsResult);
// Short-circuit expensive quertyIntentActivities calls below since we won't prompt anyways
// for protocols the browser can handle.
if (navigationChainResult == NavigationChainResult.REQUIRES_PROMPT && !isExternalProtocol) {
return OverrideUrlLoadingResult.forNoOverride();
}
// From this point on, we have determined it is safe to launch an External App from a
// fallback URL (unless we have to prompt).
if (navigationChainResult == NavigationChainResult.ALLOWED) {
canLaunchExternalFallbackResult.set(true);
}
if (shouldReturnAsResult) {
// TODO(b/353586171): Consider adding a new override type.
mDelegate.returnAsActivityResult(intentTargetUrl);
return OverrideUrlLoadingResult.forNoOverride();
}
if (mDelegate.shouldDisableAllExternalIntents()) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (resolvingInfos.get().isEmpty()) {
return handleUnresolvableIntent(
params,
targetIntent,
browserFallbackUrl,
navigationChainResult,
isExternalProtocol);
}
if (resolvesToNonExportedActivity(resolvingInfos.get())) {
return OverrideUrlLoadingResult.forNoOverride();
}
ResolveActivitySupplier resolveActivity = new ResolveActivitySupplier(targetIntent);
if (isNavigationToSelf(params, resolvingInfos, resolveActivity, isExternalProtocol)) {
return OverrideUrlLoadingResult.forNavigateTab(intentTargetUrl, params);
}
boolean hasSpecializedHandler = countSpecializedHandlers(resolvingInfos.get()) > 0;
if (!isExternalProtocol && !hasSpecializedHandler && !intentMatchesNonDefaultWebApk) {
return fallBackToHandlingInApp();
}
if (shouldStayWithinHost(params, resolvingInfos.get(), isExternalProtocol)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (shouldKeepIntentRedirectInApp(
params, incomingIntentRedirect, resolvingInfos.get(), isExternalProtocol)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (isAlreadyInTargetWebApk(resolvingInfos, params)) {
return OverrideUrlLoadingResult.forNoOverride();
}
boolean intentHasExtras =
targetIntent.getExtras() != null && !targetIntent.getExtras().isEmpty();
prepareExternalIntent(targetIntent, params, resolvingInfos.get());
if (params.isIncognito()) {
return handleIncognitoIntent(
params,
targetIntent,
intentTargetUrl,
resolvingInfos.get(),
browserFallbackUrl);
}
if (launchWebApkIfSoleIntentHandler(resolvingInfos, targetIntent, params)) {
return OverrideUrlLoadingResult.forExternalIntent();
}
boolean requiresIntentChooser = false;
if (navigationChainResult == NavigationChainResult.FOR_TRUSTED_CALLER) {
mDelegate.setPackageForTrustedCallingApp(targetIntent);
} else {
requiresIntentChooser =
isInsecureIntentToOtherBrowser(
targetIntent,
resolvingInfos,
isIntentWithSupportedProtocol,
resolveActivity,
intentHasExtras);
if (shouldAvoidShowingDisambiguationPrompt(
isExternalProtocol, intentTargetUrl, resolvingInfos, resolveActivity)) {
return OverrideUrlLoadingResult.forNoOverride();
}
if (navigationChainResult == NavigationChainResult.REQUIRES_PROMPT) {
return maybeAskToLaunchApp(
isExternalProtocol,
targetIntent,
resolvingInfos,
resolveActivity,
browserFallbackUrl,
params);
}
}
return startActivity(
targetIntent,
params,
requiresIntentChooser,
resolvingInfos,
resolveActivity,
browserFallbackUrl,
intentTargetUrl);
}
// https://crbug.com/1249964
// https://crbug.com/1418648
private boolean resolvesToNonExportedActivity(List<ResolveInfo> infos) {
for (ResolveInfo info : infos) {
// Android will prevent launching non-exported Activities in other packages.
if (info.activityInfo != null
&& !info.activityInfo.exported
&& mDelegate
.getContext()
.getPackageName()
.equals(info.activityInfo.packageName)) {
Log.w(TAG, "Web Intent resolves to non-exported Activity.");
return true;
}
}
return false;
}
private boolean shouldAvoidShowingDisambiguationPrompt(
boolean isExternalProtocol,
GURL intentTargetUrl,
QueryIntentActivitiesSupplier resolvingInfosSupplier,
ResolveActivitySupplier resolveActivitySupplier) {
// For navigations Chrome can't handle, it's fine to show the disambiguation dialog
// regardless of the embedder's preference.
if (isExternalProtocol) return false;
// Don't bother performing the package manager checks if the delegate is fine with the
// disambiguation prompt.
if (!mDelegate.shouldAvoidDisambiguationDialog(intentTargetUrl)) return false;
ResolveInfo resolveActivity = resolveActivitySupplier.get();
if (resolveActivity == null) return true;
boolean result = resolvesToChooser(resolveActivity, resolvingInfosSupplier);
if (debug() && result) Log.i(TAG, "Avoiding disambiguation dialog.");
return result;
}
private OverrideUrlLoadingResult handleIncognitoIntent(
ExternalNavigationParams params,
Intent targetIntent,
GURL intentTargetUrl,
List<ResolveInfo> resolvingInfos,
GURL browserFallbackUrl) {
boolean intentTargetedToApp = mDelegate.willAppHandleIntent(targetIntent);
GURL fallbackUrl = browserFallbackUrl;
// If we can handle the intent, then fall back to handling the target URL instead of
// the fallbackUrl if the user decides not to leave incognito.
if (resolveInfoContainsSelf(resolvingInfos)) {
GURL targetUrl =
UrlUtilities.hasIntentScheme(params.getUrl())
? intentTargetUrl
: params.getUrl();
// Make sure the browser can handle this URL, in case the Intent targeted a
// non-browser component for this app.
if (UrlUtilities.isAcceptedScheme(targetUrl)) fallbackUrl = targetUrl;
}
// 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, fallbackUrl);
}
// The intent is staying in the app, so we can simply navigate to the intent's URL,
// while staying in incognito.
return handleFallbackUrl(params, targetIntent, fallbackUrl, false);
}
/**
* Sanitize intent to be passed to {@link queryIntentActivities()}
* ensuring that web pages cannot bypass browser security.
*/
public static void sanitizeQueryIntentActivitiesIntent(Intent intent) {
intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setComponent(null);
// Intent Selectors allow intents to bypass the intent filter and potentially send apps URIs
// they were not expecting to handle. https://crbug.com/1254422
intent.setSelector(null);
}
/**
* @return OVERRIDE_WITH_EXTERNAL_INTENT when we successfully started market activity,
* NO_OVERRIDE otherwise.
*/
private OverrideUrlLoadingResult sendIntentToMarket(
String packageName,
String marketReferrer,
ExternalNavigationParams params,
GURL fallbackUrl) {
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(PLAY_APP_PACKAGE);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (!params.getReferrerUrl().isEmpty()) {
intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(params.getReferrerUrl().getSpec()));
}
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.forNoOverride();
}
if (params.isIncognito()) {
if (!startIncognitoIntent(params, intent, fallbackUrl)) {
if (debug()) Log.i(TAG, "Failed to show incognito alert dialog.");
return OverrideUrlLoadingResult.forNoOverride();
}
if (debug()) Log.i(TAG, "Incognito intent to Play Store.");
return OverrideUrlLoadingResult.forAsyncAction();
} else {
startActivity(intent, params);
if (debug()) Log.i(TAG, "Intent to Play Store.");
return OverrideUrlLoadingResult.forExternalIntent();
}
}
/**
* 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(GURL url) {
if (PLAY_HOSTNAME.equals(url.getHost()) && url.getPath().startsWith(PLAY_APP_PATH)) {
String playPackage = UrlUtilities.getValueForKeyInQuery(url, PLAY_PACKAGE_PARAM);
if (TextUtils.isEmpty(playPackage)) return null;
return new Pair<String, String>(
playPackage, UrlUtilities.getValueForKeyInQuery(url, PLAY_REFERRER_PARAM));
}
return null;
}
/** @return Whether the |url| could be handled by an external application on the system. */
@VisibleForTesting
boolean canExternalAppHandleUrl(GURL url) {
if (url.getSpec().startsWith(WTAI_MC_URL_PREFIX)) return true;
Intent intent;
try {
intent = Intent.parseUri(url.getSpec(), Intent.URI_INTENT_SCHEME);
} catch (Exception 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(
QueryIntentActivitiesSupplier resolvingInfos,
Intent targetIntent,
ExternalNavigationParams params) {
String packageName = pickWebApkIfSoleIntentHandler(params, resolvingInfos);
if (packageName == null) return false;
Intent webApkIntent = new Intent(targetIntent);
webApkIntent.setPackage(packageName);
try {
startActivity(webApkIntent, params);
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;
}
}
// https://crbug.com/1232514. See #intentMatchesNonDefaultWebApk.
private List<ResolveInfo> getResolveInfosForWebApks(
ExternalNavigationParams params, QueryIntentActivitiesSupplier resolvingInfos) {
if (params.isFromIntent() && mDelegate.shouldLaunchWebApksOnInitialIntent()) {
return resolvingInfos.getIncludingNonDefaultResolveInfos();
}
return resolvingInfos.get();
}
@Nullable
private String pickWebApkIfSoleIntentHandler(
ExternalNavigationParams params, QueryIntentActivitiesSupplier resolvingInfos) {
ArrayList<String> packages =
getSpecializedHandlers(getResolveInfosForWebApks(params, resolvingInfos));
if (packages.size() != 1 || !isValidWebApk(packages.get(0))) return null;
return packages.get(0);
}
/** 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 | PackageManager.MATCH_DEFAULT_ONLY);
}
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(GURL url) {
String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url.getSpec());
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.
*
* @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, PackageManager.MATCH_DEFAULT_ONLY);
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.
*/
private void startActivity(Intent intent, ExternalNavigationParams params) {
startActivity(intent, params, false, null, null, null, null);
}
/**
* 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 params The ExternalNavigationParams for the navigation.
* @param requiresIntentChooser Whether, for security reasons, the Intent Chooser is required to
* be shown.
* <p>Below parameters are only used if |requiresIntentChooser| is true.
* @param resolvingInfos The queryIntentActivities |intent| matches against.
* @param resolveActivity The resolving Activity |intent| matches against.
* @param browserFallbackUrl The fallback URL if the user chooses not to leave this app.
* @param intentTargetUrl The URL |intent| is targeting.
* @returns The OverrideUrlLoadingResult for starting (or not starting) the Activity.
*/
protected OverrideUrlLoadingResult startActivity(
Intent intent,
ExternalNavigationParams params,
boolean requiresIntentChooser,
QueryIntentActivitiesSupplier resolvingInfos,
ResolveActivitySupplier resolveActivity,
GURL browserFallbackUrl,
GURL intentTargetUrl) {
// https://crbug.com/330555390. If we've launched an app on the current redirect chain, we
// should never launch a second one.
if (params.getRedirectHandler().isOnNavigation()) {
params.getRedirectHandler().setShouldNotOverrideUrlLoadingOnCurrentRedirectChain();
}
// Only touches disk on Kitkat. See http://crbug.com/617725 for more context.
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
forcePdfViewerAsIntentHandlerIfNeeded(intent);
Context context = ContextUtils.activityFromContext(mDelegate.getContext());
if (context == null) {
context = ContextUtils.getApplicationContext();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
if (requiresIntentChooser) {
return startActivityWithChooser(
intent,
resolvingInfos,
resolveActivity,
browserFallbackUrl,
intentTargetUrl,
params,
context);
}
return doStartActivity(intent, context);
} 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.
} 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.");
} catch (AndroidRuntimeException e) {
// https://crbug.com/1226177: Most likely cause of this exception is Android failing
// to start the app that we previously detected could handle the Intent.
Log.e(TAG, "Could not start Activity for intent " + intent.toString(), e);
} catch (RuntimeException e) {
IntentUtils.logTransactionTooLargeOrRethrow(e, intent);
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
return OverrideUrlLoadingResult.forNoOverride();
}
private OverrideUrlLoadingResult doStartActivity(Intent intent, Context context) {
if (debug()) Log.i(TAG, "startActivity");
context.startActivity(intent);
recordExternalNavigationDispatched(intent);
return OverrideUrlLoadingResult.forExternalIntent();
}
// If the |resolvingInfos| from queryIntentActivities don't contain the result of
// resolveActivity, it means the intent is resolving to the ResolverActivity.
private boolean resolvesToChooser(
@NonNull ResolveInfo resolveActivity, QueryIntentActivitiesSupplier resolvingInfos) {
return !resolversSubsetOf(Arrays.asList(resolveActivity), resolvingInfos.get());
}
// looking up resources from other apps requires the use of getIdentifier()
@SuppressWarnings({"UseCompatLoadingForDrawables", "DiscouragedApi"})
private OverrideUrlLoadingResult startActivityWithChooser(
final Intent intent,
QueryIntentActivitiesSupplier resolvingInfos,
ResolveActivitySupplier resolveActivity,
GURL browserFallbackUrl,
GURL intentTargetUrl,
final ExternalNavigationParams params,
Context context) {
ResolveInfo intentResolveInfo = resolveActivity.get();
// If this is null, then the intent was only previously matching
// non-default filters, so just drop it.
if (intentResolveInfo == null) return OverrideUrlLoadingResult.forNoOverride();
// If we resolve to the Chooser Activity, the user will already get the option to choose the
// target app (as there will be multiple options) and we don't need to do anything.
// Otherwise we have to make a fake option in the chooser dialog that loads the URL in the
// embedding app.
if (resolvesToChooser(intentResolveInfo, resolvingInfos)) {
return doStartActivity(intent, context);
}
Intent pickerIntent = new Intent(Intent.ACTION_PICK_ACTIVITY);
pickerIntent.putExtra(Intent.EXTRA_INTENT, intent);
if (!resolveInfoContainsSelf(resolvingInfos.getIncludingNonDefaultResolveInfos())) {
// Add the fake entry for the embedding app. This behavior is not well documented but
// works consistently across Android since L (and at least up to S).
PackageManager pm = context.getPackageManager();
ArrayList<ShortcutIconResource> icons = new ArrayList<>();
ArrayList<String> labels = new ArrayList<>();
String packageName = context.getPackageName();
String label = "";
ShortcutIconResource resource = new ShortcutIconResource();
try {
ApplicationInfo applicationInfo =
pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA);
label = (String) pm.getApplicationLabel(applicationInfo);
Resources resources = pm.getResourcesForApplication(applicationInfo);
resource.packageName = packageName;
resource.resourceName = resources.getResourceName(applicationInfo.icon);
// This will throw a Resources.NotFoundException if the package uses resource
// name collapsing/stripping. The ActivityPicker fails to handle this exception, we
// have have to check for it here to avoid crashes.
resources.getDrawable(
resources.getIdentifier(resource.resourceName, null, null), null);
} catch (NameNotFoundException | Resources.NotFoundException e) {
Log.w(TAG, "No icon resource found for package: " + packageName);
// Most likely the app doesn't have an icon and is just a test
// app. Android will just use a blank icon.
resource.packageName = "";
resource.resourceName = "";
}
labels.add(label);
icons.add(resource);
pickerIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, labels);
pickerIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, icons);
}
// Call startActivityForResult on the PICK_ACTIVITY intent, which will set the component of
// the data result to the component of the chosen app.
mDelegate
.getWindowAndroid()
.showCancelableIntent(
pickerIntent,
new WindowAndroid.IntentCallback() {
@Override
public void onIntentCompleted(int resultCode, Intent data) {
RequiredCallback<AsyncActionTakenParams> callback =
params.getRequiredAsyncActionTakenCallback();
assert callback != null;
// If |data| is null, the user backed out of the intent chooser.
if (data == null) {
callback.onResult(AsyncActionTakenParams.forNoAction());
return;
}
// Quirk of how we use the ActivityChooser - if the embedding app is
// chosen we get an intent back with ACTION_CREATE_SHORTCUT.
if (data.getAction().equals(Intent.ACTION_CREATE_SHORTCUT)) {
// Ensure we don't loop asking the user to choose an app, then
// re-asking when we navigate to the same URL.
if (params.getRedirectHandler().isOnNavigation()) {
params.getRedirectHandler()
.setShouldNotOverrideUrlLoadingOnCurrentRedirectChain();
}
// It's pretty arbitrary whether to prefer the data URL or the
// fallback URL here. We could consider preferring the
// fallback URL, as the URL was probably intending to leave
// Chrome, but loading the URL the site was trying to load in
// a browser seems like the better choice
// and matches what would have happened had the regular
// chooser dialog shown up and the user selected this app.
if (UrlUtilities.isAcceptedScheme(intentTargetUrl)) {
callback.onResult(
AsyncActionTakenParams.forNavigate(
intentTargetUrl, params));
} else if (!browserFallbackUrl.isEmpty()) {
callback.onResult(
AsyncActionTakenParams.forNavigate(
browserFallbackUrl, params));
} else {
callback.onResult(AsyncActionTakenParams.forNoAction());
}
return;
}
// Set the package for the original intent to the chosen app and
// start it. Note that a selector cannot be set at the same time
// as a package.
intent.setSelector(null);
intent.setPackage(data.getComponent().getPackageName());
startActivity(intent, params);
callback.onResult(
AsyncActionTakenParams.forExternalIntentLaunched(
true, params));
}
},
null);
return OverrideUrlLoadingResult.forAsyncAction();
}
protected OverrideUrlLoadingResult maybeAskToLaunchApp(
boolean isExternalProtocol,
Intent targetIntent,
QueryIntentActivitiesSupplier resolvingInfos,
ResolveActivitySupplier resolveActivity,
GURL browserFallbackUrl,
ExternalNavigationParams params) {
// For URLs the browser supports, we shouldn't have reached here.
assert isExternalProtocol;
// Use the fallback URL if we have it, otherwise we give sites a fingerprinting mechanism
// where they can repeatedly attempt to launch apps without a user gesture until they find
// one the user has installed.
if (!browserFallbackUrl.isEmpty()) return OverrideUrlLoadingResult.forNoOverride();
ResolveInfo intentResolveInfo = resolveActivity.get();
// No app can resolve the intent, don't prompt.
if (intentResolveInfo == null || intentResolveInfo.activityInfo == null) {
if (debug()) Log.i(TAG, "Message doesn't resolve to any app.");
return OverrideUrlLoadingResult.forNoOverride();
}
// If the |resolvingInfos| from queryIntentActivities don't contain the result of
// resolveActivity, it means there's no default handler for the intent and it's resolving to
// the ResolverActivity. This means we can't know which app will be launched and can't
// convey that to the user. We also don't want to just allow the chooser dialog to be shown
// when the external navigation was otherwise blocked. In this case, we should just continue
// to block the navigation, and sites hoping to prompt the user when navigation fails should
// make sure to correctly target their app.
if (resolvesToChooser(intentResolveInfo, resolvingInfos)) {
if (debug()) Log.i(TAG, "Message resolves to multiple apps.");
return OverrideUrlLoadingResult.forNoOverride();
}
MessageDispatcher messageDispatcher =
MessageDispatcherProvider.from(mDelegate.getWindowAndroid());
WebContents webContents = mDelegate.getWebContents();
if (messageDispatcher == null || webContents == null) {
if (debug()) Log.i(TAG, "No WebContents to show Message for.");
return OverrideUrlLoadingResult.forNoOverride();
}
String packageName = intentResolveInfo.activityInfo.packageName;
PackageManager pm = mDelegate.getContext().getPackageManager();
ApplicationInfo applicationInfo = null;
try {
applicationInfo = pm.getApplicationInfo(packageName, 0);
} catch (NameNotFoundException e) {
return OverrideUrlLoadingResult.forNoOverride();
}
Drawable icon = pm.getApplicationLogo(applicationInfo);
if (icon == null) icon = pm.getApplicationIcon(applicationInfo);
CharSequence label = pm.getApplicationLabel(applicationInfo);
Resources res = mDelegate.getContext().getResources();
String title = res.getString(R.string.external_navigation_continue_to_title, label);
String description =
res.getString(R.string.external_navigation_continue_to_description, label);
String action = res.getString(R.string.external_navigation_continue_to_action);
PropertyModel message =
new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
.with(
MessageBannerProperties.MESSAGE_IDENTIFIER,
MessageIdentifier.EXTERNAL_NAVIGATION)
.with(MessageBannerProperties.TITLE, title)
.with(MessageBannerProperties.DESCRIPTION, description)
.with(MessageBannerProperties.ICON, icon)
.with(MessageBannerProperties.PRIMARY_BUTTON_TEXT, action)
.with(
MessageBannerProperties.ICON_TINT_COLOR,
MessageBannerProperties.TINT_NONE)
.with(
MessageBannerProperties.ON_PRIMARY_ACTION,
() -> {
startActivity(targetIntent, params);
var callback = params.getRequiredAsyncActionTakenCallback();
if (callback != null) {
callback.onResult(
AsyncActionTakenParams.forExternalIntentLaunched(
true, params));
}
return PrimaryActionClickBehavior.DISMISS_IMMEDIATELY;
})
.with(
MessageBannerProperties.ON_DISMISSED,
(dismissReason) -> {
if (dismissReason == DismissReason.PRIMARY_ACTION) return;
if (params.getRequiredAsyncActionTakenCallback() != null) {
params.getRequiredAsyncActionTakenCallback()
.onResult(AsyncActionTakenParams.forNoAction());
}
})
.build();
messageDispatcher.enqueueMessage(message, webContents, MessageScopeType.NAVIGATION, false);
return OverrideUrlLoadingResult.forAsyncAction();
}
/**
* 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).size();
}
/** Returns the subset of {@params infos} that are specialized intent handlers. */
private ArrayList<String> getSpecializedHandlers(List<ResolveInfo> infos) {
return getSpecializedHandlersWithFilter(infos, null);
}
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;
}
/**
* Check whether the given package is a specialized handler for given ResolveInfos.
*
* @param packageName Package name to check against. If null, checks if any package is a
* specialized handler.
* @param infos The list of ResolveInfos to check.
* @return Whether the given package (or any package if null) is a specialized handler in the
* given ResolveInfos.
*/
public static boolean isPackageSpecializedHandler(String packageName, List<ResolveInfo> infos) {
return !getSpecializedHandlersWithFilter(infos, packageName).isEmpty();
}
public static ArrayList<String> getSpecializedHandlersWithFilter(
List<ResolveInfo> infos, String filterPackageName) {
ArrayList<String> result = new ArrayList<>();
if (infos == null) {
return result;
}
for (ResolveInfo info : infos) {
if (!matchResolveInfoExceptWildCardHost(info, filterPackageName)) {
continue;
}
if (info.activityInfo != null) {
result.add(info.activityInfo.packageName);
} else {
result.add("");
}
}
return result;
}
protected boolean resolveInfoContainsSelf(List<ResolveInfo> resolveInfos) {
return resolveInfoContainsPackage(resolveInfos, mDelegate.getContext().getPackageName());
}
public static boolean resolveInfoContainsPackage(
List<ResolveInfo> resolveInfos, String packageName) {
for (ResolveInfo resolveInfo : resolveInfos) {
ActivityInfo info = resolveInfo.activityInfo;
if (info != null && packageName.equals(info.packageName)) {
return true;
}
}
return false;
}
public void onNavigationStarted(long navigationId) {
if (mIncognitoDialogDelegate != null && mIncognitoDialogDelegate.isShowing()) {
mIncognitoDialogDelegate.onNavigationStarted(navigationId);
}
}
public void onNavigationFinished(long navigationId) {
if (mIncognitoDialogDelegate != null && mIncognitoDialogDelegate.isShowing()) {
mIncognitoDialogDelegate.onNavigationFinished(navigationId);
}
}
/**
* @return Default SMS application's package name at the system level. Null if there isn't any.
*/
@VisibleForTesting
protected String getDefaultSmsPackageNameFromSystem() {
return Telephony.Sms.getDefaultSmsPackage(ContextUtils.getApplicationContext());
}
/** @return The last committed URL from the WebContents. */
@VisibleForTesting
protected GURL getLastCommittedUrl() {
if (mDelegate.getWebContents() == null) return null;
return mDelegate.getWebContents().getLastCommittedUrl();
}
/**
* @param url The requested url.
* @param permissionNeeded The name of the Android permission needed to access the file.
* @return Whether we should block the navigation and request file access before proceeding.
*/
@VisibleForTesting
protected boolean shouldRequestFileAccess(GURL url, String permissionNeeded) {
// If the tab is null, then do not attempt to prompt for access.
if (!mDelegate.hasValidTab()) return false;
assert url.getScheme().equals(UrlConstants.FILE_SCHEME);
// 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.getPath().startsWith(PathUtils.getDataDirectory())) return false;
return !mDelegate.getWindowAndroid().hasPermission(permissionNeeded)
&& mDelegate.getWindowAndroid().canRequestPermission(permissionNeeded);
}
/** @return whether this navigation is from the search results page. */
@VisibleForTesting
protected boolean isSerpReferrer() {
GURL referrerUrl = getLastCommittedUrl();
if (referrerUrl == null || referrerUrl.isEmpty()) return false;
return UrlUtilitiesJni.get().isGoogleSearchUrl(referrerUrl.getSpec());
}
private boolean isInitiatorOriginGoogleReferrer(ExternalNavigationParams params) {
Origin initiatorOrigin = params.getInitiatorOrigin();
String url =
String.format(
"%s://%s:%s",
initiatorOrigin.getScheme(),
initiatorOrigin.getHost(),
initiatorOrigin.getPort());
return UrlUtilitiesJni.get().isGoogleSubDomainUrl(url);
}
@Deprecated
private boolean isLastCommittedUrlGoogleReferrer() {
GURL referrerUrl = getLastCommittedUrl();
if (referrerUrl == null || referrerUrl.isEmpty()) return false;
return UrlUtilitiesJni.get().isGoogleSubDomainUrl(referrerUrl.getSpec());
}
/** @return whether this navigation is a redirect from an intent. */
private static boolean isIncomingIntentRedirect(ExternalNavigationParams params) {
boolean isOnEffectiveIntentRedirect =
params.getRedirectHandler().isOnNoninitialLoadForIntentNavigationChain();
return (params.isFromIntent() && params.isRedirect()) || isOnEffectiveIntentRedirect;
}
/**
* Checks whether {@param intent} is for an Instant App. Considers both package and actions that
* would resolve to Supervisor.
* @return Whether the given intent is going to open an Instant App.
*/
private static boolean isIntentToInstantApp(Intent intent) {
if (INSTANT_APP_SUPERVISOR_PKG.equals(intent.getPackage())) return true;
String intentAction = intent.getAction();
for (String action : INSTANT_APP_START_ACTIONS) {
if (action.equals(intentAction)) {
return true;
}
}
return false;
}
/**
* Adjusts the URL to account for the googlechrome:// scheme.
* Currently, its only use is to handle navigations, only http and https URL is allowed.
* @param url URL to be processed
* @return The string with the scheme and prefixes chopped off, if a valid prefix was used.
* Otherwise returns null.
*/
public static String getUrlFromSelfSchemeUrl(String selfScheme, String url) {
String prefix = selfScheme + SELF_SCHEME_NAVIGATE_PREFIX;
if (url.toLowerCase(Locale.US).startsWith(prefix)) {
String parsedUrl = url.substring(prefix.length());
if (!TextUtils.isEmpty(parsedUrl)) {
String scheme = getSanitizedUrlScheme(parsedUrl);
if (scheme == null) {
// If no scheme, assuming this is an http url.
parsedUrl = UrlConstants.HTTP_URL_PREFIX + parsedUrl;
}
}
if (UrlUtilities.isHttpOrHttps(parsedUrl)) return parsedUrl;
}
return null;
}
/**
* Parses the scheme out of the URL if possible, trimming and getting rid of unsafe characters.
* This is useful for determining if a URL has a sneaky, unsafe scheme, e.g. "java script" or
* "j$a$r". See: http://crbug.com/248398
* @return The sanitized URL scheme or null if no scheme is specified.
*/
public static String getSanitizedUrlScheme(String url) {
if (url == null) {
return null;
}
int colonIdx = url.indexOf(":");
if (colonIdx < 0) {
// No scheme specified for the url
return null;
}
String scheme = url.substring(0, colonIdx).toLowerCase(Locale.US).trim();
// Check for the presence of and get rid of all non-alphanumeric characters in the scheme,
// except dash, plus and period. Those are the only valid scheme chars:
// https://tools.ietf.org/html/rfc3986#section-3.1
boolean nonAlphaNum = false;
for (int i = 0; i < scheme.length(); i++) {
char ch = scheme.charAt(i);
if (!Character.isLetterOrDigit(ch) && ch != '-' && ch != '+' && ch != '.') {
nonAlphaNum = true;
break;
}
}
if (nonAlphaNum) {
scheme = scheme.replaceAll("[^a-z0-9.+-]", "");
}
return scheme;
}
}