// 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.externalnav;
import android.Manifest.permission;
import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnCancelListener;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.StrictMode;
import android.os.TransactionTooLargeException;
import android.provider.Browser;
import android.provider.Telephony;
import android.support.v7.app.AlertDialog;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
import org.chromium.base.ApplicationState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.PathUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.ChromeTabbedActivity2;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.externalnav.ExternalNavigationHandler.OverrideUrlLoadingResult;
import org.chromium.chrome.browser.instantapps.AuthenticatedProxyActivity;
import org.chromium.chrome.browser.instantapps.InstantAppsHandler;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.util.UrlUtilities;
import org.chromium.chrome.browser.webapps.WebappActivity;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.NavigationController;
import org.chromium.content_public.browser.NavigationEntry;
import org.chromium.content_public.common.Referrer;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.PermissionCallback;
import org.chromium.webapk.lib.client.WebApkValidator;
import java.util.ArrayList;
import java.util.List;
/**
* The main implementation of the {@link ExternalNavigationDelegate}.
*/
public class ExternalNavigationDelegateImpl implements ExternalNavigationDelegate {
private static final String TAG = "ExternalNavigationDelegateImpl";
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";
private static final String PDF_EXTENSION = "pdf";
protected final Context mApplicationContext;
private final Tab mTab;
public ExternalNavigationDelegateImpl(Tab tab) {
mTab = tab;
mApplicationContext = tab.getWindowAndroid().getApplicationContext();
}
/**
* Get a {@link Context} linked to this delegate with preference to {@link Activity}.
* The tab this delegate associates with can swap the {@link Activity} it is hosted in and
* during the swap, there might not be an available {@link Activity}.
* @return The activity {@link Context} if it can be reached.
* Application {@link Context} if not.
*/
protected final Context getAvailableContext() {
if (mTab.getWindowAndroid() == null) return mApplicationContext;
Context activityContext = WindowAndroid.activityFromContext(
mTab.getWindowAndroid().getContext().get());
if (activityContext == null) return mApplicationContext;
return activityContext;
}
/**
* 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 context Context of the app.
* @param intent Intent to open.
*/
public static void forcePdfViewerAsIntentHandlerIfNeeded(Context context, Intent intent) {
if (intent == null || !isPdfIntent(intent)) return;
resolveIntent(context, intent, true /* allowSelfOpen (ignored) */);
}
/**
* Retrieve the best activity for the given intent. If a default activity is provided,
* choose the default one. Otherwise, return the Intent picker if there are more than one
* capable activities. If the intent is pdf type, return the platform pdf viewer if
* it is available so user don't need to choose it from Intent picker.
*
* Note this function is slow on Android versions less than Lollipop.
*
* @param context Context of the app.
* @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(Context context, Intent intent, boolean allowSelfOpen) {
try {
boolean activityResolved = false;
ResolveInfo info = context.getPackageManager().resolveActivity(intent, 0);
if (info != null) {
final String packageName = context.getPackageName();
if (info.match != 0) {
// There is a default activity for this intent, use that.
if (allowSelfOpen || !packageName.equals(info.activityInfo.packageName)) {
activityResolved = true;
}
} else {
List<ResolveInfo> handlers = context.getPackageManager().queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY);
if (handlers != null && !handlers.isEmpty()) {
activityResolved = true;
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(
IntentHandler.ANDROID_APP_REFERRER_SCHEME).authority(
packageName).build();
intent.putExtra(Intent.EXTRA_REFERRER, referrer);
hasPdfViewer = true;
break;
}
}
}
if ((canSelfOpen && !allowSelfOpen) && !hasPdfViewer) {
activityResolved = false;
}
}
}
}
return activityResolved;
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
}
return false;
}
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());
}
/**
* Retrieve information about the Activity that will handle the given Intent.
*
* Note this function is slow on Android versions less than Lollipop.
*
* @param intent Intent to resolve.
* @return ResolveInfo of the Activity that will handle the Intent, or null if it failed.
*/
public static ResolveInfo resolveActivity(Intent intent) {
// This function is expensive on KK and below and should not be called from main thread.
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|| !ThreadUtils.runningOnUiThread();
try {
Context context = ContextUtils.getApplicationContext();
PackageManager pm = context.getPackageManager();
return pm.resolveActivity(intent, 0);
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
}
return null;
}
/**
* Determines whether Chrome will be handling the given Intent.
*
* Note this function is slow on Android versions less than Lollipop.
*
* @param context Context that will be firing the Intent.
* @param intent Intent that will be fired.
* @param matchDefaultOnly See {@link PackageManager#MATCH_DEFAULT_ONLY}.
* @return True if Chrome will definitely handle the intent, false otherwise.
*/
public static boolean willChromeHandleIntent(
Context context, Intent intent, boolean matchDefaultOnly) {
try {
// Early-out if the intent targets Chrome.
if (intent.getComponent() != null
&& context.getPackageName().equals(intent.getComponent().getPackageName())) {
return true;
}
// Fall back to the more expensive querying of Android when the intent doesn't target
// Chrome.
ResolveInfo info = context.getPackageManager().resolveActivity(
intent, matchDefaultOnly ? PackageManager.MATCH_DEFAULT_ONLY : 0);
return info != null
&& info.activityInfo.packageName.equals(context.getPackageName());
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
return false;
}
}
@Override
public List<ResolveInfo> queryIntentActivities(Intent intent) {
// White-list for Samsung. See http://crbug.com/613977 for more context.
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
try {
return mApplicationContext.getPackageManager().queryIntentActivities(intent,
PackageManager.GET_RESOLVED_FILTER);
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
}
@Override
public boolean willChromeHandleIntent(Intent intent) {
return willChromeHandleIntent(mApplicationContext, intent, false);
}
@Override
public boolean isSpecializedHandlerAvailable(List<ResolveInfo> infos) {
return countSpecializedHandlers(infos) > 0;
}
@Override
public boolean isWithinCurrentWebappScope(String url) {
Context context = getAvailableContext();
if (context instanceof WebappActivity) {
String scope = ((WebappActivity) context).getWebappScope();
return url.startsWith(scope);
}
return false;
}
@Override
public int countSpecializedHandlers(List<ResolveInfo> infos) {
return getSpecializedHandlersWithFilter(infos, null).size();
}
@VisibleForTesting
static ArrayList<String> getSpecializedHandlersWithFilter(
List<ResolveInfo> infos, String filterPackageName) {
ArrayList<String> result = new ArrayList<>();
if (infos == null) {
return result;
}
int count = 0;
for (ResolveInfo info : infos) {
IntentFilter filter = info.filter;
if (filter == null) {
// Error on the side of classifying ResolveInfo as generic.
continue;
}
if (filter.countDataAuthorities() == 0 && filter.countDataPaths() == 0) {
// Don't count generic handlers.
continue;
}
if (!TextUtils.isEmpty(filterPackageName)
&& (info.activityInfo == null
|| !info.activityInfo.packageName.equals(filterPackageName))) {
continue;
}
result.add(info.activityInfo != null ? info.activityInfo.packageName : "");
}
return result;
}
/**
* Check whether the given package is a specialized handler for the given intent
*
* @param context {@link Context} to use for getting the {@link PackageManager}.
* @param packageName Package name to check against. Can be null or empty.
* @param intent The intent to resolve for.
* @return Whether the given package is a specialized handler for the given intent. If there is
* no package name given checks whether there is any specialized handler.
*/
public static boolean isPackageSpecializedHandler(
Context context, String packageName, Intent intent) {
try {
List<ResolveInfo> handlers = context.getPackageManager().queryIntentActivities(
intent, PackageManager.GET_RESOLVED_FILTER);
return getSpecializedHandlersWithFilter(handlers, packageName).size() > 0;
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
}
return false;
}
@Override
public String findWebApkPackageName(List<ResolveInfo> infos) {
return WebApkValidator.findWebApkPackage(mApplicationContext, infos);
}
@Override
public String getPackageName() {
return mApplicationContext.getPackageName();
}
@Override
public void startActivity(Intent intent, boolean proxy) {
try {
forcePdfViewerAsIntentHandlerIfNeeded(mApplicationContext, intent);
if (proxy) {
dispatchAuthenticatedIntent(intent);
} else {
Context context = getAvailableContext();
if (!(context instanceof Activity)) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
recordExternalNavigationDispatched(intent);
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
}
}
@Override
public boolean startActivityIfNeeded(Intent intent, boolean proxy) {
boolean activityWasLaunched;
// Only touches disk on Kitkat. See http://crbug.com/617725 for more context.
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
StrictMode.allowThreadDiskReads();
try {
forcePdfViewerAsIntentHandlerIfNeeded(mApplicationContext, intent);
if (proxy) {
dispatchAuthenticatedIntent(intent);
activityWasLaunched = true;
} else {
Context context = getAvailableContext();
if (context instanceof Activity) {
activityWasLaunched = ((Activity) context).startActivityIfNeeded(intent, -1);
} else {
activityWasLaunched = false;
}
}
if (activityWasLaunched) recordExternalNavigationDispatched(intent);
return activityWasLaunched;
} catch (RuntimeException e) {
logTransactionTooLargeOrRethrow(e, intent);
return false;
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
}
private void recordExternalNavigationDispatched(Intent intent) {
ArrayList<String> specializedHandlers = intent.getStringArrayListExtra(
IntentHandler.EXTRA_EXTERNAL_NAV_PACKAGES);
if (specializedHandlers != null && specializedHandlers.size() > 0) {
RecordUserAction.record("MobileExternalNavigationDispatched");
}
}
/**
* Shows an alert dialog prompting the user to leave incognito mode.
*
* @param activity The {@link Activity} to launch the dialog from.
* @param onAccept Will be called when the user chooses to leave incognito.
* @param onCancel Will be called when the user declines to leave incognito.
*/
public static void showLeaveIncognitoWarningDialog(Activity activity,
final OnClickListener onAccept, final OnCancelListener onCancel) {
new AlertDialog.Builder(activity, R.style.AlertDialogTheme)
.setTitle(R.string.external_app_leave_incognito_warning_title)
.setMessage(R.string.external_app_leave_incognito_warning)
.setPositiveButton(R.string.ok, onAccept)
.setNegativeButton(R.string.cancel, new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
onCancel.onCancel(dialog);
}
})
.setOnCancelListener(onCancel)
.show();
}
@Override
public void startIncognitoIntent(final Intent intent, final String referrerUrl,
final String fallbackUrl, final Tab tab, final boolean needsToCloseTab,
final boolean proxy) {
Context context = tab.getWindowAndroid().getContext().get();
if (!(context instanceof Activity)) return;
showLeaveIncognitoWarningDialog((Activity) context,
new OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
startActivity(intent, proxy);
if (tab != null && !tab.isClosing() && tab.isInitialized()
&& needsToCloseTab) {
closeTab(tab);
}
}
},
new OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
loadIntent(intent, referrerUrl, fallbackUrl, tab, needsToCloseTab, true);
}
});
}
@Override
public boolean shouldRequestFileAccess(String url, Tab tab) {
// If the tab is null, then do not attempt to prompt for access.
if (tab == null) return false;
// If the url points inside of Chromium's data directory, no permissions are necessary.
// This is required to prevent permission prompt when uses wants to access offline pages.
if (url.startsWith("file://" + PathUtils.getDataDirectory())) {
return false;
}
return !tab.getWindowAndroid().hasPermission(permission.WRITE_EXTERNAL_STORAGE)
&& tab.getWindowAndroid().canRequestPermission(permission.WRITE_EXTERNAL_STORAGE);
}
@Override
public void startFileIntent(final Intent intent, final String referrerUrl, final Tab tab,
final boolean needsToCloseTab) {
PermissionCallback permissionCallback = new PermissionCallback() {
@Override
public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
loadIntent(intent, referrerUrl, null, tab, needsToCloseTab, tab.isIncognito());
} else {
// TODO(tedchoc): Show an indication to the user that the navigation failed
// instead of silently dropping it on the floor.
if (needsToCloseTab) {
// If the access was not granted, then close the tab if necessary.
closeTab(tab);
}
}
}
};
tab.getWindowAndroid().requestPermissions(
new String[] {permission.WRITE_EXTERNAL_STORAGE}, permissionCallback);
}
private void loadIntent(Intent intent, String referrerUrl, String fallbackUrl, Tab tab,
boolean needsToCloseTab, boolean launchIncogntio) {
boolean needsToStartIntent = false;
if (tab == null || tab.isClosing() || !tab.isInitialized()) {
needsToStartIntent = true;
needsToCloseTab = false;
} else if (needsToCloseTab) {
needsToStartIntent = true;
}
String url = fallbackUrl != null ? fallbackUrl : intent.getDataString();
if (!UrlUtilities.isAcceptedScheme(url)) {
if (needsToCloseTab) closeTab(tab);
return;
}
if (needsToStartIntent) {
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
intent.putExtra(Browser.EXTRA_APPLICATION_ID, getPackageName());
if (launchIncogntio) intent.putExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, true);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setClassName(getPackageName(), ChromeLauncherActivity.class.getName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
IntentHandler.addTrustedIntentExtras(intent, mApplicationContext);
startActivity(intent, false);
if (needsToCloseTab) closeTab(tab);
return;
}
LoadUrlParams loadUrlParams = new LoadUrlParams(url, PageTransition.AUTO_TOPLEVEL);
if (!TextUtils.isEmpty(referrerUrl)) {
Referrer referrer = new Referrer(referrerUrl, Referrer.REFERRER_POLICY_ALWAYS);
loadUrlParams.setReferrer(referrer);
}
tab.loadUrl(loadUrlParams);
}
@Override
public OverrideUrlLoadingResult clobberCurrentTab(
String url, String referrerUrl, Tab tab) {
int transitionType = PageTransition.LINK;
LoadUrlParams loadUrlParams = new LoadUrlParams(url, transitionType);
if (!TextUtils.isEmpty(referrerUrl)) {
Referrer referrer = new Referrer(referrerUrl, Referrer.REFERRER_POLICY_ALWAYS);
loadUrlParams.setReferrer(referrer);
}
if (tab != null) {
tab.loadUrl(loadUrlParams);
return OverrideUrlLoadingResult.OVERRIDE_WITH_CLOBBERING_TAB;
} else {
assert false : "clobberCurrentTab was called with an empty tab.";
Uri uri = Uri.parse(url);
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.putExtra(Browser.EXTRA_APPLICATION_ID, getPackageName());
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setPackage(getPackageName());
startActivity(intent, false);
return OverrideUrlLoadingResult.OVERRIDE_WITH_EXTERNAL_INTENT;
}
}
@Override
public boolean isChromeAppInForeground() {
return ApplicationStatus.getStateForApplication()
== ApplicationState.HAS_RUNNING_ACTIVITIES;
}
@Override
public void maybeSetWindowId(Intent intent) {
Context context = getAvailableContext();
if (!(context instanceof ChromeTabbedActivity2)) return;
intent.putExtra(IntentHandler.EXTRA_WINDOW_ID, 2);
}
@Override
public String getDefaultSmsPackageName() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return null;
return Telephony.Sms.getDefaultSmsPackage(mApplicationContext);
}
private static void logTransactionTooLargeOrRethrow(RuntimeException e, Intent intent) {
// See http://crbug.com/369574.
if (e.getCause() instanceof TransactionTooLargeException) {
Log.e(TAG, "Could not resolve Activity for intent " + intent.toString(), e);
} else {
throw e;
}
}
private void closeTab(Tab tab) {
Context context = tab.getWindowAndroid().getContext().get();
if (context instanceof ChromeActivity) {
((ChromeActivity) context).getTabModelSelector().closeTab(tab);
}
}
@Override
public boolean isPdfDownload(String url) {
String fileExtension = MimeTypeMap.getFileExtensionFromUrl(url);
if (TextUtils.isEmpty(fileExtension)) return false;
return PDF_EXTENSION.equals(fileExtension);
}
@Override
public void maybeRecordAppHandlersInIntent(Intent intent, List<ResolveInfo> infos) {
intent.putExtra(IntentHandler.EXTRA_EXTERNAL_NAV_PACKAGES,
getSpecializedHandlersWithFilter(infos, null));
}
@Override
public boolean isSerpReferrer(String referrerUrl, Tab tab) {
if (tab == null || tab.getWebContents() == null) {
return false;
}
NavigationController nController = tab.getWebContents().getNavigationController();
int index = nController.getLastCommittedEntryIndex();
if (index == -1) return false;
NavigationEntry entry = nController.getEntryAtIndex(index);
if (entry == null) return false;
return UrlUtilities.nativeIsGoogleSearchUrl(entry.getUrl());
}
@Override
public boolean maybeLaunchInstantApp(Tab tab, String url, String referrerUrl,
boolean isIncomingRedirect) {
if (tab == null || tab.getWebContents() == null) return false;
InstantAppsHandler handler = InstantAppsHandler.getInstance();
Intent intent = tab.getTabRedirectHandler() != null
? tab.getTabRedirectHandler().getInitialIntent() : null;
// TODO(mariakhomenko): consider also handling NDEF_DISCOVER action redirects.
if (isIncomingRedirect && intent != null && intent.getAction() == Intent.ACTION_VIEW) {
// Set the URL the redirect was resolved to for checking the existence of the
// instant app inside handleIncomingIntent().
Intent resolvedIntent = new Intent(intent);
resolvedIntent.setData(Uri.parse(url));
return handler.handleIncomingIntent(getAvailableContext(), resolvedIntent,
ChromeLauncherActivity.isCustomTabIntent(resolvedIntent));
} else if (!isIncomingRedirect) {
// Check if the navigation is coming from SERP and skip instant app handling.
if (isSerpReferrer(referrerUrl, tab)) return false;
return handler.handleNavigation(
getAvailableContext(), url,
TextUtils.isEmpty(referrerUrl) ? null : Uri.parse(referrerUrl),
tab.getWebContents());
}
return false;
}
/**
* Dispatches the intent through a proxy activity, so that startActivityForResult can be used
* and the intent recipient can verify the caller.
* @param intent The bare intent we were going to send.
*/
protected void dispatchAuthenticatedIntent(Intent intent) {
Intent proxyIntent = new Intent(Intent.ACTION_MAIN);
proxyIntent.setClass(getAvailableContext(), AuthenticatedProxyActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
proxyIntent.putExtra(AuthenticatedProxyActivity.AUTHENTICATED_INTENT_EXTRA, intent);
getAvailableContext().startActivity(proxyIntent);
}
}