| // 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.DialogInterface; |
| 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.view.WindowManager.BadTokenException; |
| import android.webkit.MimeTypeMap; |
| import android.webkit.WebView; |
| |
| import androidx.annotation.IntDef; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.appcompat.app.AlertDialog; |
| |
| 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.base.MimeTypeUtils; |
| import org.chromium.ui.base.PageTransition; |
| import org.chromium.ui.base.WindowAndroid; |
| 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.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"; |
| |
| // 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"}; |
| |
| /** |
| * Histogram for the result of an intent scheme navigation. |
| * This enum is used in UMA, do not reorder values. |
| */ |
| @IntDef({IntentUriNavigationResult.WITH_FALLBACK_LAUNCHED_INTENT, |
| IntentUriNavigationResult.WITH_FALLBACK_USED_FALLBACK, |
| IntentUriNavigationResult.WITH_FALLBACK_NO_OVERRIDE, |
| IntentUriNavigationResult.WITH_FALLBACK_ASYNC_RESULT, |
| IntentUriNavigationResult.NO_FALLBACK_LAUNCHED_INTENT, |
| IntentUriNavigationResult.NO_FALLBACK_NO_OVERRIDE, |
| IntentUriNavigationResult.NO_FALLBACK_ASYNC_RESULT, |
| IntentUriNavigationResult.NUM_ENTRIES}) |
| @Retention(RetentionPolicy.SOURCE) |
| public @interface IntentUriNavigationResult { |
| /* Intent with an unused fallback URL was launched. */ |
| int WITH_FALLBACK_LAUNCHED_INTENT = 0; |
| /* Intent fallback URL was used. */ |
| int WITH_FALLBACK_USED_FALLBACK = 1; |
| /* Intent with an unused fallback URL was blocked. */ |
| int WITH_FALLBACK_NO_OVERRIDE = 2; |
| /* Intent with a fallback URL prompted the user. */ |
| int WITH_FALLBACK_ASYNC_RESULT = 3; |
| /* Intent without a fallback URL was launched. */ |
| int NO_FALLBACK_LAUNCHED_INTENT = 4; |
| /* Intent without a fallback URL was blocked. */ |
| int NO_FALLBACK_NO_OVERRIDE = 5; |
| /* Intent without a fallback URL prompted the user. */ |
| int NO_FALLBACK_ASYNC_RESULT = 6; |
| |
| int NUM_ENTRIES = 7; |
| } |
| |
| private static final String INTENT_URI_RESULT_NAME = "Android.Intent.IntentUriNavigationResult"; |
| |
| // 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(); |
| } |
| } |
| |
| // 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({OverrideUrlLoadingAsyncActionType.UI_GATING_BROWSER_NAVIGATION, |
| OverrideUrlLoadingAsyncActionType.UI_GATING_INTENT_LAUNCH, |
| OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION}) |
| @Retention(RetentionPolicy.SOURCE) |
| public @interface OverrideUrlLoadingAsyncActionType { |
| /* The user has been presented with a consent dialog gating a browser navigation. */ |
| int UI_GATING_BROWSER_NAVIGATION = 0; |
| /* The user has been presented with a consent dialog gating an intent launch. */ |
| int UI_GATING_INTENT_LAUNCH = 1; |
| /* No async action has been taken. */ |
| int NO_ASYNC_ACTION = 2; |
| |
| int NUM_ENTRIES = 3; |
| } |
| |
| /** |
| * 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; |
| |
| @OverrideUrlLoadingAsyncActionType |
| int mAsyncActionType; |
| |
| boolean mWasExternalFallbackUrlLaunch; |
| |
| GURL mTargetUrl; |
| ExternalNavigationParams mExternalNavigationParams; |
| |
| private OverrideUrlLoadingResult(@OverrideUrlLoadingResultType int resultType) { |
| this(resultType, OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION, false); |
| } |
| |
| private OverrideUrlLoadingResult(GURL targetUrl, ExternalNavigationParams params) { |
| this(OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB, |
| OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION, false); |
| mTargetUrl = targetUrl; |
| mExternalNavigationParams = params; |
| } |
| |
| private OverrideUrlLoadingResult(@OverrideUrlLoadingResultType int resultType, |
| @OverrideUrlLoadingAsyncActionType int asyncActionType, |
| boolean wasExternalFallbackUrlLaunch) { |
| // The async action type should be set only for async actions... |
| assert (asyncActionType == OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION |
| || resultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION); |
| |
| // ...and it *must* be set for async actions. |
| assert (!(asyncActionType == OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION |
| && resultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION)); |
| |
| assert (!wasExternalFallbackUrlLaunch |
| || resultType == OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT); |
| |
| mResultType = resultType; |
| mAsyncActionType = asyncActionType; |
| mWasExternalFallbackUrlLaunch = wasExternalFallbackUrlLaunch; |
| } |
| |
| public @OverrideUrlLoadingResultType int getResultType() { |
| return mResultType; |
| } |
| |
| public @OverrideUrlLoadingAsyncActionType int getAsyncActionType() { |
| return mAsyncActionType; |
| } |
| |
| 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( |
| @OverrideUrlLoadingAsyncActionType int asyncActionType) { |
| return new OverrideUrlLoadingResult( |
| OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION, asyncActionType, |
| 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, |
| OverrideUrlLoadingAsyncActionType.NO_ASYNC_ACTION, true); |
| } |
| } |
| |
| public static boolean sAllowIntentsToSelfForTesting; |
| private final ExternalNavigationDelegate mDelegate; |
| private AlertDialog mIncognitoAlertDialog; |
| |
| /** |
| * Constructs a new instance of {@link ExternalNavigationHandler}, using the injected |
| * {@link ExternalNavigationDelegate}. |
| */ |
| public ExternalNavigationHandler(ExternalNavigationDelegate delegate) { |
| mDelegate = delegate; |
| } |
| |
| 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(https://crbug.com/1096099): Refactor shouldOverrideUrlLoadingInternal, splitting it |
| // up to separate out the notions wanting to fire an external intent vs being able to. |
| MutableBoolean canLaunchExternalFallbackResult = new MutableBoolean(); |
| |
| long time = SystemClock.elapsedRealtime(); |
| OverrideUrlLoadingResult 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 (isIntentUrl) captureIntentSchemeMetrics(result, browserFallbackUrl); |
| |
| 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() |
| || (params.getRedirectHandler().isOnNavigation() |
| // For instance, if this is a chained fallback URL, we ignore it. |
| && params.getRedirectHandler().shouldNotOverrideUrlLoading())) { |
| 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; |
| } |
| } |
| |
| // NOTE: any further redirection from fall-back URL should not override URL loading. |
| // Otherwise, it can be used in chain for fingerprinting multiple app installation |
| // status in one shot. In order to prevent this scenario, we notify redirection |
| // handler that redirection from the current navigation should stay in this app. |
| if (params.getRedirectHandler().isOnNavigation() |
| && !params.getRedirectHandler() |
| .getAndClearShouldNotBlockOverrideUrlLoadingOnCurrentRedirectionChain()) { |
| params.getRedirectHandler().setShouldNotOverrideUrlLoadingOnCurrentRedirectChain(); |
| } |
| |
| 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 void captureIntentSchemeMetrics( |
| OverrideUrlLoadingResult result, GURL browserFallbackUrl) { |
| @IntentUriNavigationResult |
| int value = IntentUriNavigationResult.NO_FALLBACK_NO_OVERRIDE; |
| if (browserFallbackUrl.isEmpty()) { |
| switch (result.getResultType()) { |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT: |
| value = IntentUriNavigationResult.NO_FALLBACK_LAUNCHED_INTENT; |
| break; |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION: |
| value = IntentUriNavigationResult.NO_FALLBACK_ASYNC_RESULT; |
| break; |
| case OverrideUrlLoadingResultType.NO_OVERRIDE: |
| value = IntentUriNavigationResult.NO_FALLBACK_NO_OVERRIDE; |
| break; |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB: |
| // Quirk of incognito intent scheme URLs synchronously clobbering the tab with |
| // the target URL when the dialog can't be shown. |
| value = IntentUriNavigationResult.NO_FALLBACK_NO_OVERRIDE; |
| break; |
| default: |
| assert false; |
| break; |
| } |
| } else { |
| switch (result.getResultType()) { |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_EXTERNAL_INTENT: |
| if (result.wasExternalFallbackUrlLaunch()) { |
| value = IntentUriNavigationResult.WITH_FALLBACK_USED_FALLBACK; |
| } else { |
| value = IntentUriNavigationResult.WITH_FALLBACK_LAUNCHED_INTENT; |
| } |
| break; |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_ASYNC_ACTION: |
| value = IntentUriNavigationResult.WITH_FALLBACK_ASYNC_RESULT; |
| break; |
| case OverrideUrlLoadingResultType.NO_OVERRIDE: |
| value = IntentUriNavigationResult.WITH_FALLBACK_NO_OVERRIDE; |
| break; |
| case OverrideUrlLoadingResultType.OVERRIDE_WITH_NAVIGATE_TAB: |
| value = IntentUriNavigationResult.WITH_FALLBACK_USED_FALLBACK; |
| break; |
| default: |
| assert false; |
| break; |
| } |
| } |
| |
| RecordHistogram.recordEnumeratedHistogram( |
| INTENT_URI_RESULT_NAME, value, IntentUriNavigationResult.NUM_ENTRIES); |
| } |
| |
| 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()); |
| RecordHistogram.recordEnumeratedHistogram( |
| "Android.Intent.OpenFileType", mimeType, MimeTypeUtils.NUM_MIME_TYPE_ENTRIES); |
| 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) { |
| 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(https://crbug.com/1346731): 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. |
| if (mDelegate.isForTrustedCallingApp(resolvingInfos)) { |
| 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) { |
| 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, "Subframe 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, "Subframe 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 isCrossFrameRenavigation(ExternalNavigationParams params) { |
| if (!ExternalIntentsFeatures.BLOCK_FRAME_RENAVIGATIONS.isEnabled()) return false; |
| |
| if (params.getRedirectHandler().navigationChainPerformedCrossFrameNavigation()) { |
| if (debug()) Log.i(TAG, "Navigation chain used cross-frame re-navigation."); |
| return true; |
| } |
| |
| if (params.isInitialNavigationInFrame() || !params.isCrossFrameNavigation()) 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().setPerformedCrossFrameNavigation(); |
| 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( |
| OverrideUrlLoadingAsyncActionType.UI_GATING_INTENT_LAUNCH); |
| } |
| 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 intent The intent for external application that will be sent. |
| * @param referrerUrl The referrer for the current navigation. |
| * @param fallbackUrl The URL to load if the user doesn't proceed with external intent. |
| * @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; |
| } |
| |
| try { |
| mIncognitoAlertDialog = showLeavingIncognitoAlert(context, params, intent, fallbackUrl); |
| return mIncognitoAlertDialog != null; |
| } catch (BadTokenException e) { |
| return false; |
| } |
| } |
| |
| @VisibleForTesting |
| protected boolean canLaunchIncognitoIntent(Intent intent, Context context) { |
| if (!mDelegate.hasValidTab()) return false; |
| if (ContextUtils.activityFromContext(context) == null) return false; |
| return true; |
| } |
| |
| /** |
| * Shows and returns an AlertDialog asking if the user would like to leave incognito. |
| */ |
| @VisibleForTesting |
| protected AlertDialog showLeavingIncognitoAlert(final Context context, |
| final ExternalNavigationParams params, final Intent intent, final GURL fallbackUrl) { |
| // https://crbug.com/1412842: It seems dialogs sometimes end up with multiple results |
| // chosen. |
| final AtomicBoolean dialogResultChosen = new AtomicBoolean(false); |
| return new AlertDialog.Builder(context, R.style.ThemeOverlay_BrowserUI_AlertDialog) |
| .setTitle(R.string.external_app_leave_incognito_warning_title) |
| .setMessage(R.string.external_app_leave_incognito_warning) |
| .setPositiveButton(R.string.external_app_leave_incognito_leave, |
| (DialogInterface dialog, int which) -> { |
| if (dialogResultChosen.get()) return; |
| dialogResultChosen.set(true); |
| onUserDecidedWhetherToLaunchIncognitoIntent( |
| /*shouldLaunch=*/true, params, intent, fallbackUrl); |
| }) |
| .setNegativeButton(R.string.external_app_leave_incognito_stay, |
| (DialogInterface dialog, int which) -> { |
| if (dialogResultChosen.get()) return; |
| dialogResultChosen.set(true); |
| onUserDecidedWhetherToLaunchIncognitoIntent( |
| /*shouldLaunch=*/false, params, intent, fallbackUrl); |
| }) |
| .setOnCancelListener((DialogInterface dialog) -> { |
| if (dialogResultChosen.get()) return; |
| dialogResultChosen.set(true); |
| onUserDecidedWhetherToLaunchIncognitoIntent( |
| /*shouldLaunch=*/false, params, intent, fallbackUrl); |
| }) |
| .setOnDismissListener((DialogInterface dialog) -> { mIncognitoAlertDialog = null; }) |
| .show(); |
| } |
| |
| private void onUserDecidedWhetherToLaunchIncognitoIntent(final boolean shouldLaunch, |
| final ExternalNavigationParams params, final Intent intent, final GURL fallbackUrl) { |
| if (shouldLaunch) { |
| try { |
| startActivity(intent); |
| 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 AlertDialog. |
| if (mIncognitoAlertDialog != null && mIncognitoAlertDialog.isShowing()) { |
| mIncognitoAlertDialog.cancel(); |
| } |
| |
| // 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 intentDataUrl = new GURL(targetIntent.getDataString()); |
| // 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(intentDataUrl); |
| |
| // 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 (isCrossFrameRenavigation(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( |
| OverrideUrlLoadingAsyncActionType.UI_GATING_BROWSER_NAVIGATION); |
| } |
| |
| // 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(); |
| } |
| |
| @NavigationChainResult |
| int navigationChainResult = navigationChainBlocksExternalNavigation( |
| params, targetIntent, resolvingInfos, isExternalProtocol); |
| |
| // 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 (resolvingInfos.get().isEmpty()) { |
| return handleUnresolvableIntent( |
| params, targetIntent, browserFallbackUrl, navigationChainResult); |
| } |
| |
| if (resolvesToNonExportedActivity(resolvingInfos.get())) { |
| return OverrideUrlLoadingResult.forNoOverride(); |
| } |
| |
| ResolveActivitySupplier resolveActivity = new ResolveActivitySupplier(targetIntent); |
| if (isNavigationToSelf(params, resolvingInfos, resolveActivity, isExternalProtocol)) { |
| return OverrideUrlLoadingResult.forNavigateTab(intentDataUrl, 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, intentDataUrl, 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, intentDataUrl, resolvingInfos, resolveActivity)) { |
| return OverrideUrlLoadingResult.forNoOverride(); |
| } |
| if (navigationChainResult == NavigationChainResult.REQUIRES_PROMPT) { |
| return maybeAskToLaunchApp(isExternalProtocol, targetIntent, resolvingInfos, |
| resolveActivity, browserFallbackUrl, params); |
| } |
| } |
| |
| return startActivity(targetIntent, requiresIntentChooser, resolvingInfos, resolveActivity, |
| browserFallbackUrl, intentDataUrl, params); |
| } |
| |
| // 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 intentDataUrl, 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(intentDataUrl)) 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 intentDataUrl, 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()) ? intentDataUrl : 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( |
| OverrideUrlLoadingAsyncActionType.UI_GATING_INTENT_LAUNCH); |
| } else { |
| startActivity(intent); |
| 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); |
| 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) { |
| startActivity(intent, false, null, 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 requiresIntentChooser Whether, for security reasons, the Intent Chooser is required to |
| * be shown. |
| * |
| * 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 intentDataUrl The URL |intent| is targeting. |
| * @param params The ExternalNavigationParams for the navigation. |
| * @returns The OverrideUrlLoadingResult for starting (or not starting) the Activity. |
| */ |
| protected OverrideUrlLoadingResult startActivity(Intent intent, boolean requiresIntentChooser, |
| QueryIntentActivitiesSupplier resolvingInfos, ResolveActivitySupplier resolveActivity, |
| GURL browserFallbackUrl, GURL intentDataUrl, ExternalNavigationParams params) { |
| // 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, intentDataUrl, 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 intentDataUrl, 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. |
| 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(intentDataUrl)) { |
| callback.onResult( |
| AsyncActionTakenParams.forNavigate(intentDataUrl, 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); |
| callback.onResult( |
| AsyncActionTakenParams.forExternalIntentLaunched(true, params)); |
| } |
| }, null); |
| return OverrideUrlLoadingResult.forAsyncAction( |
| OverrideUrlLoadingAsyncActionType.UI_GATING_INTENT_LAUNCH); |
| } |
| |
| 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); |
| if (params.getRequiredAsyncActionTakenCallback() != null) { |
| params.getRequiredAsyncActionTakenCallback().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( |
| OverrideUrlLoadingAsyncActionType.UI_GATING_INTENT_LAUNCH); |
| } |
| |
| /** |
| * 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; |
| } |
| |
| /** |
| * @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; |
| } |
| } |