// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser;
import android.app.KeyguardManager;
import android.app.PendingIntent;
import android.app.SearchManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.SystemClock;
import android.provider.Browser;
import android.provider.MediaStore;
import android.speech.RecognizerResultsIntent;
import android.text.TextUtils;
import android.util.Pair;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.externalauth.ExternalAuthUtils;
import org.chromium.chrome.browser.externalnav.ExternalNavigationDelegateImpl;
import org.chromium.chrome.browser.externalnav.IntentWithGesturesHandler;
import org.chromium.chrome.browser.omnibox.AutocompleteController;
import org.chromium.chrome.browser.rappor.RapporServiceBridge;
import org.chromium.chrome.browser.search_engines.TemplateUrlService;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.document.ActivityDelegate;
import org.chromium.chrome.browser.util.IntentUtils;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.common.Referrer;
import org.chromium.ui.base.PageTransition;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
/**
* Handles all browser-related Intents.
*/
public class IntentHandler {
private static final String TAG = "IntentHandler";
/**
* Document mode: If true, Chrome is launched into the same Task.
* Note: used by first-party applications, do not rename.
*/
public static final String EXTRA_APPEND_TASK = "com.android.chrome.append_task";
/**
* Document mode: If true, keep tasks in Recents when a user hits back at the root URL.
* Note: used by first-party applications, do not rename.
*/
public static final String EXTRA_PRESERVE_TASK = "com.android.chrome.preserve_task";
/**
* Document mode: If true, opens the document in background.
* Note: used by first-party applications, do not rename.
*/
public static final String EXTRA_OPEN_IN_BG = "com.android.chrome.open_with_affiliation";
/**
* Document mode: Records what caused a document to be created.
*/
public static final String EXTRA_STARTED_BY = "com.android.chrome.started_by";
/**
* Tab ID to use when creating a new Tab.
*/
public static final String EXTRA_TAB_ID = "com.android.chrome.tab_id";
/**
* The tab id of the parent tab, if any.
*/
public static final String EXTRA_PARENT_TAB_ID = "com.android.chrome.parent_tab_id";
/**
* Intent to bring the parent Activity back, if the parent Tab lives in a different Activity.
*/
public static final String EXTRA_PARENT_INTENT = "com.android.chrome.parent_intent";
/**
* ComponentName of the parent Activity. Can be used by an Activity launched on top of another
* Activity (e.g. BookmarkActivity) to intent back into the Activity it sits on top of.
*/
public static final String EXTRA_PARENT_COMPONENT =
"org.chromium.chrome.browser.parent_component";
/**
* Transition type is only set internally by a first-party app and has to be signed.
*/
public static final String EXTRA_PAGE_TRANSITION_TYPE = "com.google.chrome.transition_type";
/**
* The original intent of the given intent before it was modified.
*/
public static final String EXTRA_ORIGINAL_INTENT = "com.android.chrome.original_intent";
/**
* An extra to indicate that a particular intent was triggered from the first run experience
* flow.
*/
public static final String EXTRA_INVOKED_FROM_FRE = "com.android.chrome.invoked_from_fre";
/**
* An extra to indicate that the intent was triggered from a launcher shortcut.
*/
public static final String EXTRA_INVOKED_FROM_SHORTCUT =
"com.android.chrome.invoked_from_shortcut";
/**
* Intent extra used to identify the sending application.
*/
private static final String TRUSTED_APPLICATION_CODE_EXTRA = "trusted_application_code_extra";
/**
* The scheme for referrer coming from an application.
*/
public static final String ANDROID_APP_REFERRER_SCHEME = "android-app";
/**
* A referrer id used for Chrome to Chrome referrer passing.
*/
public static final String EXTRA_REFERRER_ID = "org.chromium.chrome.browser.referrer_id";
/**
* Key to associate a timestamp with an intent.
*/
private static final String EXTRA_TIMESTAMP_MS = "org.chromium.chrome.browser.timestamp";
/**
* For multi-window, passes the id of the window.
*/
public static final String EXTRA_WINDOW_ID = "org.chromium.chrome.browser.window_id";
/**
* Records package names of other applications in the system that could have handled
* this intent.
*/
public static final String EXTRA_EXTERNAL_NAV_PACKAGES = "org.chromium.chrome.browser.eenp";
/**
* A hash code for the URL to verify intent data hasn't been modified.
*/
public static final String EXTRA_DATA_HASH_CODE = "org.chromium.chrome.browser.data_hash";
/**
* Fake ComponentName used in constructing TRUSTED_APPLICATION_CODE_EXTRA.
*/
private static ComponentName sFakeComponentName = null;
private static final Object LOCK = new Object();
private static Pair<Integer, String> sPendingReferrer;
private static int sReferrerId;
private static String sPendingIncognitoUrl;
private static final String PACKAGE_GSA = "com.google.android.googlequicksearchbox";
private static final String PACKAGE_GMAIL = "com.google.android.gm";
private static final String PACKAGE_PLUS = "com.google.android.apps.plus";
private static final String PACKAGE_HANGOUTS = "com.google.android.talk";
private static final String PACKAGE_MESSENGER = "com.google.android.apps.messaging";
private static final String PACKAGE_LINE = "jp.naver.line.android";
private static final String PACKAGE_WHATSAPP = "com.whatsapp";
private static final String FACEBOOK_LINK_PREFIX = "http://m.facebook.com/l.php?";
private static final String TWITTER_LINK_PREFIX = "http://t.co/";
private static final String NEWS_LINK_PREFIX = "http://news.google.com/news/url?";
/**
* Represents popular external applications that can load a page in Chrome via intent.
*/
public static enum ExternalAppId {
OTHER,
GMAIL,
FACEBOOK,
PLUS,
TWITTER,
CHROME,
HANGOUTS,
MESSENGER,
NEWS,
LINE,
WHATSAPP,
GSA,
INDEX_BOUNDARY
}
private static ComponentName getFakeComponentName(String packageName) {
synchronized (LOCK) {
if (sFakeComponentName == null) {
sFakeComponentName = new ComponentName(packageName, "FakeClass");
}
}
return sFakeComponentName;
}
/** Intent extra to open an incognito tab. */
public static final String EXTRA_OPEN_NEW_INCOGNITO_TAB =
"com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB";
/** Schemes used by web pages to start up Chrome without an explicit Intent. */
public static final String GOOGLECHROME_SCHEME = "googlechrome";
public static final String GOOGLECHROME_NAVIGATE_PREFIX =
GOOGLECHROME_SCHEME + "://navigate?url=";
/**
* The class name to be specified in the ComponentName for Intents that are creating a new
* tab (regardless of whether the user is in document or tabbed mode).
*/
// TODO(tedchoc): Remove this and directly reference the Launcher activity when that becomes
// publicly available.
private static final String TAB_ACTIVITY_COMPONENT_CLASS_NAME =
"com.google.android.apps.chrome.Main";
private static boolean sTestIntentsEnabled;
private final IntentHandlerDelegate mDelegate;
private final String mPackageName;
private KeyguardManager mKeyguardManager;
public static enum TabOpenType {
OPEN_NEW_TAB,
// Tab is reused only if the URLs perfectly match.
REUSE_URL_MATCHING_TAB_ELSE_NEW_TAB,
// Tab is reused only if there's an existing tab opened by the same app ID.
REUSE_APP_ID_MATCHING_TAB_ELSE_NEW_TAB,
CLOBBER_CURRENT_TAB,
BRING_TAB_TO_FRONT,
// Opens a new incognito tab.
OPEN_NEW_INCOGNITO_TAB,
}
/**
* A delegate interface for users of IntentHandler.
*/
public static interface IntentHandlerDelegate {
/**
* Processes a URL VIEW Intent.
*/
void processUrlViewIntent(String url, String referer, String headers,
TabOpenType tabOpenType, String externalAppId, int tabIdToBringToFront,
boolean hasUserGesture, Intent intent);
void processWebSearchIntent(String query);
}
/** Sets whether or not test intents are enabled. */
@VisibleForTesting
public static void setTestIntentsEnabled(boolean enabled) {
sTestIntentsEnabled = enabled;
}
public IntentHandler(IntentHandlerDelegate delegate, String packageName) {
mDelegate = delegate;
mPackageName = packageName;
}
/**
* Determines what App was used to fire this Intent.
* @param packageName Package name of this application.
* @param intent Intent that was used to launch Chrome.
* @return ExternalAppId representing the app.
*/
public static ExternalAppId determineExternalIntentSource(String packageName, Intent intent) {
String appId = IntentUtils.safeGetStringExtra(intent, Browser.EXTRA_APPLICATION_ID);
ExternalAppId externalId = ExternalAppId.OTHER;
if (appId == null) {
String url = getUrlFromIntent(intent);
if (url != null && url.startsWith(TWITTER_LINK_PREFIX)) {
externalId = ExternalAppId.TWITTER;
} else if (url != null && url.startsWith(FACEBOOK_LINK_PREFIX)) {
externalId = ExternalAppId.FACEBOOK;
} else if (url != null && url.startsWith(NEWS_LINK_PREFIX)) {
externalId = ExternalAppId.NEWS;
}
} else {
if (appId.equals(PACKAGE_PLUS)) {
externalId = ExternalAppId.PLUS;
} else if (appId.equals(PACKAGE_GMAIL)) {
externalId = ExternalAppId.GMAIL;
} else if (appId.equals(PACKAGE_HANGOUTS)) {
externalId = ExternalAppId.HANGOUTS;
} else if (appId.equals(PACKAGE_MESSENGER)) {
externalId = ExternalAppId.MESSENGER;
} else if (appId.equals(PACKAGE_LINE)) {
externalId = ExternalAppId.LINE;
} else if (appId.equals(PACKAGE_WHATSAPP)) {
externalId = ExternalAppId.WHATSAPP;
} else if (appId.equals(PACKAGE_GSA)) {
externalId = ExternalAppId.GSA;
} else if (appId.equals(packageName)) {
externalId = ExternalAppId.CHROME;
}
}
return externalId;
}
private void recordExternalIntentSourceUMA(Intent intent) {
ExternalAppId externalId = determineExternalIntentSource(mPackageName, intent);
RecordHistogram.recordEnumeratedHistogram("MobileIntent.PageLoadDueToExternalApp",
externalId.ordinal(), ExternalAppId.INDEX_BOUNDARY.ordinal());
if (externalId == ExternalAppId.OTHER) {
String appId = IntentUtils.safeGetStringExtra(intent, Browser.EXTRA_APPLICATION_ID);
if (!TextUtils.isEmpty(appId)) {
RapporServiceBridge.sampleString("Android.PageLoadDueToExternalApp", appId);
}
}
}
/**
* Records an action when a user chose to handle a URL in Chrome that could have been handled
* by an application installed on the phone. Also records the name of that application.
* This doesn't include generic URL handlers, such as browsers.
*/
private void recordAppHandlersForIntent(Intent intent) {
List<String> packages = IntentUtils.safeGetStringArrayListExtra(intent,
IntentHandler.EXTRA_EXTERNAL_NAV_PACKAGES);
if (packages != null && packages.size() > 0) {
RecordUserAction.record("MobileExternalNavigationReceived");
for (String name : packages) {
RapporServiceBridge.sampleString("Android.ExternalNavigationNotChosen", name);
}
}
}
/**
* Handles an Intent after the ChromeTabbedActivity decides that it shouldn't ignore the
* Intent.
*
* @return Whether the Intent was successfully handled.
*/
boolean onNewIntent(Context context, Intent intent) {
assert intentHasValidUrl(intent);
String url = getUrlFromIntent(intent);
boolean hasUserGesture =
IntentWithGesturesHandler.getInstance().getUserGestureAndClear(intent);
TabOpenType tabOpenType = getTabOpenType(intent);
int tabIdToBringToFront = IntentUtils.safeGetIntExtra(
intent, TabOpenType.BRING_TAB_TO_FRONT.name(), Tab.INVALID_TAB_ID);
if (url == null && tabIdToBringToFront == Tab.INVALID_TAB_ID
&& tabOpenType != TabOpenType.OPEN_NEW_INCOGNITO_TAB) {
return handleWebSearchIntent(intent);
}
String referrerUrl = getReferrerUrlIncludingExtraHeaders(intent, context);
String extraHeaders = getExtraHeadersFromIntent(intent);
// TODO(joth): Presumably this should check the action too.
mDelegate.processUrlViewIntent(url, referrerUrl, extraHeaders, tabOpenType,
IntentUtils.safeGetStringExtra(intent, Browser.EXTRA_APPLICATION_ID),
tabIdToBringToFront, hasUserGesture, intent);
recordExternalIntentSourceUMA(intent);
recordAppHandlersForIntent(intent);
return true;
}
/**
* Extracts referrer Uri from intent, if supplied.
* @param intent The intent to use.
* @return The referrer Uri.
*/
private static Uri getReferrer(Intent intent) {
Uri referrer = IntentUtils.safeGetParcelableExtra(intent, Intent.EXTRA_REFERRER);
if (referrer != null) {
return referrer;
}
String referrerName = IntentUtils.safeGetStringExtra(intent, Intent.EXTRA_REFERRER_NAME);
if (referrerName != null) {
return Uri.parse(referrerName);
}
return null;
}
/**
* Extracts referrer URL string. The extra is used if we received it from a first party app or
* if the referrer_extra is specified as android-app://package style URL.
* @param intent The intent from which to extract the URL.
* @param context The activity that received the intent.
* @return The URL string or null if none should be used.
*/
private static String getReferrerUrl(Intent intent, Context context) {
Uri referrerExtra = getReferrer(intent);
if (referrerExtra == null) return null;
String referrerUrl = IntentHandler.getPendingReferrerUrl(
IntentUtils.safeGetIntExtra(intent, EXTRA_REFERRER_ID, 0));
if (!TextUtils.isEmpty(referrerUrl)) {
return referrerUrl;
} else if (isValidReferrerHeader(referrerExtra.toString())) {
return referrerExtra.toString();
} else if (IntentHandler.isIntentChromeOrFirstParty(intent, context)) {
return referrerExtra.toString();
}
return null;
}
/**
* Gets the referrer, looking in the Intent extra and in the extra headers extra.
*
* The referrer extra takes priority over the "extra headers" one.
*
* @param intent The Intent containing the extras.
* @param context The application context.
* @return The referrer, or null.
*/
public static String getReferrerUrlIncludingExtraHeaders(Intent intent, Context context) {
String referrerUrl = getReferrerUrl(intent, context);
if (referrerUrl != null) return referrerUrl;
Bundle bundleExtraHeaders = IntentUtils.safeGetBundleExtra(intent, Browser.EXTRA_HEADERS);
if (bundleExtraHeaders == null) return null;
for (String key : bundleExtraHeaders.keySet()) {
String value = bundleExtraHeaders.getString(key);
if ("referer".equals(key.toLowerCase(Locale.US)) && isValidReferrerHeader(value)) {
return value;
}
}
return null;
}
/**
* Add referrer and extra headers to a {@link LoadUrlParams}, if we managed to parse them from
* the intent.
* @param params The {@link LoadUrlParams} to add referrer and headers.
* @param intent The intent we use to parse the extras.
*/
public static void addReferrerAndHeaders(LoadUrlParams params, Intent intent, Context context) {
String referrer = getReferrerUrlIncludingExtraHeaders(intent, context);
if (referrer != null) {
params.setReferrer(new Referrer(referrer, Referrer.REFERRER_POLICY_DEFAULT));
}
String headers = getExtraHeadersFromIntent(intent);
if (headers != null) params.setVerbatimHeaders(headers);
}
/**
* @return Whether that the given referrer is of the format that Chrome allows external
* apps to specify.
*/
private static boolean isValidReferrerHeader(String referrer) {
return referrer != null
&& referrer.toLowerCase(Locale.US).startsWith(ANDROID_APP_REFERRER_SCHEME + "://");
}
/**
* Constructs a valid referrer using the given authority.
* @param authority The authority to use.
* @return Referrer with default policy that uses the valid android app scheme.
*/
public static Referrer constructValidReferrerForAuthority(String authority) {
return new Referrer(new Uri.Builder().scheme(ANDROID_APP_REFERRER_SCHEME)
.authority(authority).build().toString(), Referrer.REFERRER_POLICY_DEFAULT);
}
/**
* Extracts the URL from voice search result intent.
* @return URL if it was found, null otherwise.
*/
static String getUrlFromVoiceSearchResult(Intent intent) {
if (!RecognizerResultsIntent.ACTION_VOICE_SEARCH_RESULTS.equals(intent.getAction())) {
return null;
}
ArrayList<String> results = IntentUtils.safeGetStringArrayListExtra(
intent, RecognizerResultsIntent.EXTRA_VOICE_SEARCH_RESULT_STRINGS);
// Allow specifying a single voice result via the command line during testing (as the
// 'am' command does not allow specifying an array of strings).
if (results == null && sTestIntentsEnabled) {
String testResult = IntentUtils.safeGetStringExtra(
intent, RecognizerResultsIntent.EXTRA_VOICE_SEARCH_RESULT_STRINGS);
if (testResult != null) {
results = new ArrayList<String>();
results.add(testResult);
}
}
if (results == null || results.size() == 0) return null;
String query = results.get(0);
String url = AutocompleteController.nativeQualifyPartialURLQuery(query);
if (url == null) {
List<String> urls = IntentUtils.safeGetStringArrayListExtra(
intent, RecognizerResultsIntent.EXTRA_VOICE_SEARCH_RESULT_URLS);
if (urls != null && urls.size() > 0) {
url = urls.get(0);
} else {
url = TemplateUrlService.getInstance().getUrlForVoiceSearchQuery(query);
}
}
return url;
}
public boolean handleWebSearchIntent(Intent intent) {
if (intent == null) return false;
String query = null;
final String action = intent.getAction();
if (Intent.ACTION_SEARCH.equals(action)
|| MediaStore.INTENT_ACTION_MEDIA_SEARCH.equals(action)) {
query = IntentUtils.safeGetStringExtra(intent, SearchManager.QUERY);
}
if (query == null || TextUtils.isEmpty(query)) return false;
mDelegate.processWebSearchIntent(query);
return true;
}
private static PendingIntent getAuthenticationToken(Context appContext) {
Intent fakeIntent = new Intent();
fakeIntent.setComponent(getFakeComponentName(appContext.getPackageName()));
return PendingIntent.getActivity(appContext, 0, fakeIntent, 0);
}
/**
* Start activity for the given trusted Intent.
*
* To make sure the intent is not dropped by Chrome, we send along an authentication token to
* identify ourselves as a trusted sender. The method {@link #shouldIgnoreIntent} validates the
* token.
*/
public static void startActivityForTrustedIntent(Intent intent, Context context) {
startActivityForTrustedIntentInternal(intent, context, null);
}
/**
* Start the activity that handles launching tabs in Chrome given the trusted intent.
*
* This allows specifying URLs that chrome:// handles internally, but does not expose in
* intent-filters for global use.
*
* To make sure the intent is not dropped by Chrome, we send along an authentication token to
* identify ourselves as a trusted sender. The method {@link #shouldIgnoreIntent} validates the
* token.
*/
public static void startChromeLauncherActivityForTrustedIntent(Intent intent, Context context) {
// Specify the exact component that will handle creating a new tab. This allows specifying
// URLs that are not exposed in the intent filters (i.e. chrome://).
startActivityForTrustedIntentInternal(intent, context, new ComponentName(
context.getPackageName(), TAB_ACTIVITY_COMPONENT_CLASS_NAME));
}
private static void startActivityForTrustedIntentInternal(
Intent intent, Context context, ComponentName componentName) {
// The caller might want to re-use the Intent, so we'll use a copy.
Intent copiedIntent = new Intent(intent);
if (componentName != null) {
assert copiedIntent.getComponent() == null;
// Specify the exact component that will handle creating a new tab. This allows
// specifying URLs that are not exposed in the intent filters (i.e. chrome://).
copiedIntent.setComponent(componentName);
}
addTrustedIntentExtras(copiedIntent, context);
// Make sure we use the application context.
Context appContext = context.getApplicationContext();
appContext.startActivity(copiedIntent);
}
/**
* Sets TRUSTED_APPLICATION_CODE_EXTRA on the provided intent to identify it as coming from
* a trusted source.
*/
public static void addTrustedIntentExtras(Intent intent, Context context) {
if (ExternalNavigationDelegateImpl.willChromeHandleIntent(context, intent, true)) {
// The PendingIntent functions as an authentication token --- it could only have come
// from us. Stash it in the real Intent as an extra. shouldIgnoreIntent will retrieve it
// and check it with isIntentChromeInternal.
intent.putExtra(TRUSTED_APPLICATION_CODE_EXTRA,
getAuthenticationToken(context.getApplicationContext()));
// It is crucial that we never leak the authentication token to other packages, because
// then the other package could be used to impersonate us/do things as us. Therefore,
// scope the real Intent to our package.
intent.setPackage(context.getApplicationContext().getPackageName());
}
}
/**
* Returns a String (or null) containing the extra headers sent by the intent, if any.
*
* This methods skips the referrer header.
*
* @param intent The intent containing the bundle extra with the HTTP headers.
*/
public static String getExtraHeadersFromIntent(Intent intent) {
Bundle bundleExtraHeaders = IntentUtils.safeGetBundleExtra(intent, Browser.EXTRA_HEADERS);
if (bundleExtraHeaders == null) return null;
StringBuilder extraHeaders = new StringBuilder();
Iterator<String> keys = bundleExtraHeaders.keySet().iterator();
while (keys.hasNext()) {
String key = keys.next();
String value = bundleExtraHeaders.getString(key);
if ("referer".equals(key.toLowerCase(Locale.US))) continue;
if (extraHeaders.length() != 0) extraHeaders.append("\n");
extraHeaders.append(key);
extraHeaders.append(": ");
extraHeaders.append(value);
}
return extraHeaders.length() == 0 ? null : extraHeaders.toString();
}
/**
* Adds a timestamp to an intent, as returned by {@link SystemClock#elapsedRealtime()}.
*
* To track page load time, this needs to be called as close as possible to
* the entry point (in {@link Activity#onCreate()} for instance).
*/
public static void addTimestampToIntent(Intent intent) {
intent.putExtra(EXTRA_TIMESTAMP_MS, SystemClock.elapsedRealtime());
}
/**
* @return the timestamp associated with an intent, or -1.
*/
public static long getTimestampFromIntent(Intent intent) {
return intent.getLongExtra(EXTRA_TIMESTAMP_MS, -1);
}
/**
* Returns true if the app should ignore a given intent.
*
* @param context Android Context.
* @param intent Intent to check.
* @return true if the intent should be ignored.
*/
public boolean shouldIgnoreIntent(Context context, Intent intent) {
// Although not documented to, many/most methods that retrieve values from an Intent may
// throw. Because we can't control what packages might send to us, we should catch any
// Throwable and then fail closed (safe). This is ugly, but resolves top crashers in the
// wild.
try {
// Ignore all invalid URLs, regardless of what the intent was.
if (!intentHasValidUrl(intent)) {
return true;
}
// Determine if this intent came from a trustworthy source (either Chrome or Google
// first party applications).
boolean isInternal = isIntentChromeOrFirstParty(intent, context);
// "Open new incognito tab" is currently limited to Chrome or first parties.
if (!isInternal
&& IntentUtils.safeGetBooleanExtra(
intent, EXTRA_OPEN_NEW_INCOGNITO_TAB, false)
&& (getPendingIncognitoUrl() == null
|| !getPendingIncognitoUrl().equals(intent.getDataString()))) {
return true;
}
// Now if we have an empty URL and the intent was ACTION_MAIN,
// we are pretty sure it is the launcher calling us to show up.
// We can safely ignore the screen state.
String url = getUrlFromIntent(intent);
if (url == null && Intent.ACTION_MAIN.equals(intent.getAction())) {
return false;
}
// Ignore all intents that specify a Chrome internal scheme if they did not come from
// a trustworthy source.
String scheme = getSanitizedUrlScheme(url);
if (!isInternal && scheme != null
&& (intent.hasCategory(Intent.CATEGORY_BROWSABLE)
|| intent.hasCategory(Intent.CATEGORY_DEFAULT)
|| intent.getCategories() == null)) {
String lowerCaseScheme = scheme.toLowerCase(Locale.US);
if ("chrome".equals(lowerCaseScheme) || "chrome-native".equals(lowerCaseScheme)
|| "about".equals(lowerCaseScheme)) {
// Allow certain "safe" internal URLs to be launched by external
// applications.
String lowerCaseUrl = url.toLowerCase(Locale.US);
if ("about:blank".equals(lowerCaseUrl)
|| "about://blank".equals(lowerCaseUrl)) {
return false;
}
Log.w(TAG, "Ignoring internal Chrome URL from untrustworthy source.");
return true;
}
}
// We must check for screen state at this point.
// These might be slow.
boolean internalOrVisible = isInternal || isIntentUserVisible(context);
return !internalOrVisible;
} catch (Throwable t) {
return true;
}
}
@VisibleForTesting
boolean intentHasValidUrl(Intent intent) {
String url = getUrlFromIntent(intent);
// Always drop insecure urls.
if (url != null && isJavascriptSchemeOrInvalidUrl(url)) {
return false;
}
return true;
}
/**
* Fetch the authentication token (a PendingIntent) created by startActivityForTrustedIntent,
* if any. If anything goes wrong trying to retrieve the token (examples include
* BadParcelableException or ClassNotFoundException), fail closed.
*/
private static PendingIntent fetchAuthenticationTokenFromIntent(Intent intent) {
return (PendingIntent) IntentUtils.safeGetParcelableExtra(
intent, TRUSTED_APPLICATION_CODE_EXTRA);
}
private static boolean isChromeToken(PendingIntent token, Context context) {
// Fetch what should be a matching token.
Context appContext = context.getApplicationContext();
PendingIntent pending = getAuthenticationToken(appContext);
return pending.equals(token);
}
/**
* @param intent An Intent to be checked.
* @param context A context.
* @return Whether an intent originates from Chrome.
*/
public static boolean wasIntentSenderChrome(Intent intent, Context context) {
if (intent == null) return false;
PendingIntent token = fetchAuthenticationTokenFromIntent(intent);
if (token == null) return false;
// Do not ignore a valid URL Intent if the sender is Chrome. (If the PendingIntents are
// equal, we know that the sender was us.)
return isChromeToken(token, context);
}
/**
* @param intent An Intent to be checked.
* @param context A context.
* @return Whether an intent originates from Chrome or a first-party app.
*/
public static boolean isIntentChromeOrFirstParty(Intent intent, Context context) {
if (intent == null) return false;
PendingIntent token = fetchAuthenticationTokenFromIntent(intent);
if (token == null) return false;
// Do not ignore a valid URL Intent if the sender is Chrome. (If the PendingIntents are
// equal, we know that the sender was us.)
if (isChromeToken(token, context)) {
return true;
}
if (ExternalAuthUtils.getInstance().isGoogleSigned(
context, ApiCompatibilityUtils.getCreatorPackage(token))) {
return true;
}
return false;
}
private boolean isIntentUserVisible(Context context) {
// Only process Intents if the screen is on and the device is unlocked;
// i.e. the user will see what is going on.
if (mKeyguardManager == null) {
mKeyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE);
}
if (!ApiCompatibilityUtils.isInteractive(context)) return false;
return !ApiCompatibilityUtils.isDeviceProvisioned(context)
|| !mKeyguardManager.inKeyguardRestrictedInputMode();
}
/*
* The default behavior here is to open in a new tab. If this is changed, ensure
* intents with action NDEF_DISCOVERED (links beamed over NFC) are handled properly.
*/
private TabOpenType getTabOpenType(Intent intent) {
if (IntentUtils.safeGetBooleanExtra(
intent, ShortcutHelper.REUSE_URL_MATCHING_TAB_ELSE_NEW_TAB, false)) {
return TabOpenType.REUSE_URL_MATCHING_TAB_ELSE_NEW_TAB;
}
if (IntentUtils.safeGetBooleanExtra(intent, EXTRA_OPEN_NEW_INCOGNITO_TAB, false)) {
return TabOpenType.OPEN_NEW_INCOGNITO_TAB;
}
if (IntentUtils.safeGetIntExtra(intent, TabOpenType.BRING_TAB_TO_FRONT.name(),
Tab.INVALID_TAB_ID) != Tab.INVALID_TAB_ID) {
return TabOpenType.BRING_TAB_TO_FRONT;
}
String appId = IntentUtils.safeGetStringExtra(intent, Browser.EXTRA_APPLICATION_ID);
// Due to users complaints, we are NOT reusing tabs for apps that do not specify an appId.
if (appId == null
|| IntentUtils.safeGetBooleanExtra(intent, Browser.EXTRA_CREATE_NEW_TAB, false)) {
return TabOpenType.OPEN_NEW_TAB;
}
// Intents from chrome open in the same tab by default, all others only clobber
// tabs created by the same app.
return mPackageName.equals(appId) ? TabOpenType.CLOBBER_CURRENT_TAB
: TabOpenType.REUSE_APP_ID_MATCHING_TAB_ELSE_NEW_TAB;
}
private boolean isInvalidScheme(String scheme) {
return scheme != null && (scheme.toLowerCase(Locale.US).equals("javascript")
|| scheme.toLowerCase(Locale.US).equals("jar"));
}
/**
* Parses the scheme out of the URL if possible, trimming and getting rid of unsafe characters.
* This is useful for determining if a URL has a sneaky, unsafe scheme, e.g. "java script" or
* "j$a$r". See: http://crbug.com/248398
* @return The sanitized URL scheme or null if no scheme is specified.
*/
private String getSanitizedUrlScheme(String url) {
if (url == null) {
return null;
}
int colonIdx = url.indexOf(":");
if (colonIdx < 0) {
// No scheme specified for the url
return null;
}
String scheme = url.substring(0, colonIdx).toLowerCase(Locale.US).trim();
// Check for the presence of and get rid of all non-alphanumeric characters in the scheme,
// except dash, plus and period. Those are the only valid scheme chars:
// https://tools.ietf.org/html/rfc3986#section-3.1
boolean nonAlphaNum = false;
for (char ch : scheme.toCharArray()) {
if (!Character.isLetterOrDigit(ch) && ch != '-' && ch != '+' && ch != '.') {
nonAlphaNum = true;
break;
}
}
if (nonAlphaNum) {
scheme = scheme.replaceAll("[^a-z0-9.+-]", "");
}
return scheme;
}
private boolean isJavascriptSchemeOrInvalidUrl(String url) {
String urlScheme = getSanitizedUrlScheme(url);
return isInvalidScheme(urlScheme);
}
/**
* Retrieve the URL from the Intent, which may be in multiple locations.
* @param intent Intent to examine.
* @return URL from the Intent, or null if a valid URL couldn't be found.
*/
public static String getUrlFromIntent(Intent intent) {
if (intent == null) return null;
String url = getUrlFromVoiceSearchResult(intent);
if (url == null) url = ActivityDelegate.getInitialUrlForDocument(intent);
if (url == null) url = getUrlForCustomTab(intent);
if (url == null) url = intent.getDataString();
if (url == null) return null;
url = url.trim();
if (isGoogleChromeScheme(url)) {
url = getUrlFromGoogleChromeSchemeUrl(url);
}
return TextUtils.isEmpty(url) ? null : url;
}
private static String getUrlForCustomTab(Intent intent) {
if (intent == null || intent.getData() == null) return null;
Uri data = intent.getData();
return TextUtils.equals(data.getScheme(), UrlConstants.CUSTOM_TAB_SCHEME)
? data.getQuery() : null;
}
/**
* Adjusts the URL to account for the googlechrome:// scheme.
* Currently, its only use is to handle navigations.
* @param url URL to be processed
* @return The string with the scheme and prefixes chopped off, if a valid prefix was used.
* Otherwise returns null.
*/
public static String getUrlFromGoogleChromeSchemeUrl(String url) {
if (url.toLowerCase(Locale.US).startsWith(GOOGLECHROME_NAVIGATE_PREFIX)) {
return url.substring(GOOGLECHROME_NAVIGATE_PREFIX.length());
}
return null;
}
/**
* @param url URL to be tested
* @return Whether the given URL adheres to the googlechrome:// scheme definition.
*/
public static boolean isGoogleChromeScheme(String url) {
if (url == null) return false;
String urlScheme = Uri.parse(url).getScheme();
return urlScheme != null && urlScheme.equals(GOOGLECHROME_SCHEME);
}
// TODO(mariakhomenko): pending referrer and pending incognito intent could potentially
// not work correctly in multi-window. Store per-window information instead.
/**
* Records a pending referrer URL that we may be sending to ourselves through an intent.
* @param intent The intent to which we add a referrer.
* @param url The referrer URL.
*/
public static void setPendingReferrer(Intent intent, String url) {
intent.putExtra(Intent.EXTRA_REFERRER, Uri.parse(url));
intent.putExtra(IntentHandler.EXTRA_REFERRER_ID, ++sReferrerId);
sPendingReferrer = new Pair<Integer, String>(sReferrerId, url);
}
/**
* Clears any pending referrer data.
*/
public static void clearPendingReferrer() {
sPendingReferrer = null;
}
/**
* Retrieves pending referrer URL based on the given id.
* @param id The referrer id.
* @return The URL for the referrer or null if none found.
*/
public static String getPendingReferrerUrl(int id) {
if (sPendingReferrer != null && (sPendingReferrer.first == id)) {
return sPendingReferrer.second;
}
return null;
}
/**
* Keeps track of pending incognito URL to be loaded and ensures we allow to load it if it
* comes back to us. This is a method for dispatching incognito URL intents from Chrome that
* may or may not end up in Chrome.
* @param intent The intent that will be sent.
*/
public static void setPendingIncognitoUrl(Intent intent) {
if (intent.getData() != null) {
intent.putExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, true);
sPendingIncognitoUrl = intent.getDataString();
}
}
/**
* Clears the pending incognito URL.
*/
public static void clearPendingIncognitoUrl() {
sPendingIncognitoUrl = null;
}
/**
* @return Pending incognito URL that is allowed to be loaded without system token.
*/
public static String getPendingIncognitoUrl() {
return sPendingIncognitoUrl;
}
/**
* Some applications may request to load the URL with a particular transition type.
* @param context The application context.
* @param intent Intent causing the URL load, may be null.
* @param defaultTransition The transition to return if none specified in the intent.
* @return The transition type to use for loading the URL.
*/
public static int getTransitionTypeFromIntent(Context context, Intent intent,
int defaultTransition) {
if (intent == null) return defaultTransition;
int transitionType = IntentUtils.safeGetIntExtra(
intent, IntentHandler.EXTRA_PAGE_TRANSITION_TYPE, PageTransition.LINK);
if (transitionType == PageTransition.TYPED) {
return transitionType;
} else if (transitionType != PageTransition.LINK
&& isIntentChromeOrFirstParty(intent, context)) {
// 1st party applications may specify any transition type.
return transitionType;
}
return defaultTransition;
}
}