blob: f9b18e8736eb2029591345bf4fe2070cf216d60f [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.download;
import android.Manifest.permission;
import android.app.Activity;
import android.app.DownloadManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Environment;
import android.support.v7.app.AlertDialog;
import android.text.TextUtils;
import android.util.Pair;
import android.view.View;
import android.webkit.MimeTypeMap;
import android.widget.TextView;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.infobar.InfoBarIdentifier;
import org.chromium.chrome.browser.infobar.SimpleConfirmInfoBarBuilder;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.content.browser.ContentViewDownloadDelegate;
import org.chromium.content.browser.DownloadController;
import org.chromium.content.browser.DownloadInfo;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.PermissionCallback;
import org.chromium.ui.widget.Toast;
import java.io.File;
/**
* Chrome implementation of the ContentViewDownloadDelegate interface.
*
* Listens to POST and GET download events. GET download requests are passed along to the
* Android Download Manager. POST downloads are expected to be handled natively and listener
* is responsible for adding the completed download to the download manager.
*
* Prompts the user when a dangerous file is downloaded. Auto-opens PDFs after downloading.
*/
public class ChromeDownloadDelegate implements ContentViewDownloadDelegate {
private static final String TAG = "Download";
private class DangerousDownloadListener implements SimpleConfirmInfoBarBuilder.Listener {
@Override
public void onInfoBarButtonClicked(boolean confirm) {
assert mTab != null;
if (mPendingRequest == null) return;
if (mPendingRequest.hasDownloadId()) {
nativeDangerousDownloadValidated(mTab, mPendingRequest.getDownloadId(), confirm);
if (confirm) {
showDownloadStartNotification();
}
closeBlankTab();
} else if (confirm) {
// User confirmed the download.
if (mPendingRequest.isGETRequest()) {
final DownloadInfo info = mPendingRequest;
new AsyncTask<Void, Void, Pair<String, String>>() {
@Override
protected Pair<String, String> doInBackground(Void... params) {
Pair<String, String> result = getDownloadDirectoryNameAndFullPath();
String fullDirPath = result.second;
return doesFileAlreadyExists(fullDirPath, info.getFileName())
? result : null;
}
@Override
protected void onPostExecute(Pair<String, String> result) {
if (result != null) {
// File already exists.
String dirName = result.first;
String fullDirPath = result.second;
launchDownloadInfoBar(info, dirName, fullDirPath);
} else {
enqueueDownloadManagerRequest(info);
}
}
}.execute();
} else {
DownloadInfo newDownloadInfo = DownloadInfo.Builder.fromDownloadInfo(
mPendingRequest).setIsSuccessful(true).build();
DownloadManagerService.getDownloadManagerService(mContext).onDownloadCompleted(
newDownloadInfo);
}
} else {
// User did not accept the download, discard the file if it is a POST download.
if (!mPendingRequest.isGETRequest()) {
discardFile(mPendingRequest.getFilePath());
}
}
mPendingRequest = null;
}
@Override
public void onInfoBarDismissed() {
if (mPendingRequest != null) {
if (mPendingRequest.hasDownloadId()) {
assert mTab != null;
nativeDangerousDownloadValidated(mTab, mPendingRequest.getDownloadId(), false);
} else if (!mPendingRequest.isGETRequest()) {
// Infobar was dismissed, discard the file if a POST download is pending.
discardFile(mPendingRequest.getFilePath());
}
}
// Forget the pending request.
mPendingRequest = null;
}
}
private final DangerousDownloadListener mDangerousDownloadListener;
// The application context.
private final Context mContext;
private Tab mTab;
private final TabModelSelector mTabModelSelector;
// Pending download request for a dangerous file.
private DownloadInfo mPendingRequest;
/**
* Creates ChromeDownloadDelegate.
* @param context The application context.
* @param tabModelSelector The TabModelSelector responsible for {@code mTab}.
* @param tab The corresponding tab instance.
*/
public ChromeDownloadDelegate(
Context context, TabModelSelector tabModelSelector, Tab tab) {
mContext = context;
mTab = tab;
mTab.addObserver(new EmptyTabObserver() {
@Override
public void onDestroyed(Tab tab) {
mTab = null;
}
});
mTabModelSelector = tabModelSelector;
mPendingRequest = null;
mDangerousDownloadListener = new DangerousDownloadListener();
}
/**
* Request a download from the given url, or if a streaming viewer is available stream the
* content into the viewer.
* @param downloadInfo Information about the download.
*/
@Override
public void requestHttpGetDownload(DownloadInfo downloadInfo) {
// If we're dealing with A/V content that's not explicitly marked for download, check if it
// is streamable.
if (!DownloadManagerService.isAttachment(downloadInfo.getContentDisposition())) {
// Query the package manager to see if there's a registered handler that matches.
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse(downloadInfo.getUrl()), downloadInfo.getMimeType());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// If the intent is resolved to ourselves, we don't want to attempt to load the url
// only to try and download it again.
if (DownloadManagerService.openIntent(mContext, intent, false)) {
return;
}
}
onDownloadStartNoStream(downloadInfo);
}
/**
* Notify the host application a download should be done, even if there is a
* streaming viewer available for this type.
*
* @param downloadInfo Information about the download.
*/
protected void onDownloadStartNoStream(final DownloadInfo downloadInfo) {
final String fileName = downloadInfo.getFileName();
assert !TextUtils.isEmpty(fileName);
final String newMimeType =
remapGenericMimeType(downloadInfo.getMimeType(), downloadInfo.getUrl(), fileName);
new AsyncTask<Void, Void, Object[]>() {
@Override
protected Object[] doInBackground(Void... params) {
// Check to see if we have an SDCard.
String status = Environment.getExternalStorageState();
Pair<String, String> result = getDownloadDirectoryNameAndFullPath();
String dirName = result.first;
String fullDirPath = result.second;
boolean fileExists = doesFileAlreadyExists(fullDirPath, fileName);
return new Object[] {status, dirName, fullDirPath, fileExists};
}
@Override
protected void onPostExecute(Object[] result) {
String externalStorageState = (String) result[0];
String dirName = (String) result[1];
String fullDirPath = (String) result[2];
Boolean fileExists = (Boolean) result[3];
if (!checkExternalStorageAndNotify(
fileName, fullDirPath, externalStorageState)) {
return;
}
String url = sanitizeDownloadUrl(downloadInfo);
if (url == null) return;
DownloadInfo newInfo = DownloadInfo.Builder.fromDownloadInfo(downloadInfo)
.setUrl(url)
.setMimeType(newMimeType)
.setDescription(url)
.setFileName(fileName)
.setIsGETRequest(true)
.build();
// TODO(acleung): This is a temp fix to disable auto downloading if flash files.
// We want to avoid downloading flash files when it is linked as an iframe.
// The proper fix would be to let chrome knows which frame originated the request.
if ("application/x-shockwave-flash".equals(newInfo.getMimeType())) return;
if (isDangerousFile(fileName, newMimeType)) {
confirmDangerousDownload(newInfo);
} else {
// Not a dangerous file, proceed.
if (fileExists) {
launchDownloadInfoBar(newInfo, dirName, fullDirPath);
} else {
enqueueDownloadManagerRequest(newInfo);
}
}
}
}.execute();
}
/**
* Sanitize the URL for the download item.
*
* @param downloadInfo Information about the download.
*/
protected String sanitizeDownloadUrl(DownloadInfo downloadInfo) {
return downloadInfo.getUrl();
}
/**
* Request user confirmation on a dangerous download.
*
* @param downloadInfo Information about the download.
*/
private void confirmDangerousDownload(DownloadInfo downloadInfo) {
// A Dangerous file is already pending user confirmation, ignore the new download.
if (mPendingRequest != null) return;
// Tab is already destroyed, no need to add an infobar.
if (mTab == null) return;
mPendingRequest = downloadInfo;
int drawableId = R.drawable.infobar_warning;
final String titleText = nativeGetDownloadWarningText(mPendingRequest.getFileName());
final String okButtonText = mContext.getResources().getString(R.string.ok);
final String cancelButtonText = mContext.getResources().getString(R.string.cancel);
SimpleConfirmInfoBarBuilder.create(mTab, mDangerousDownloadListener,
InfoBarIdentifier.CONFIRM_DANGEROUS_DOWNLOAD, drawableId, titleText, okButtonText,
cancelButtonText, true);
}
/**
* Called when a danagerous download is about to start.
*
* @param filename File name of the download item.
* @param downloadId ID of the download.
*/
@Override
public void onDangerousDownload(String filename, int downloadId) {
DownloadInfo downloadInfo = new DownloadInfo.Builder()
.setFileName(filename)
.setDescription(filename)
.setHasDownloadId(true)
.setDownloadId(downloadId).build();
confirmDangerousDownload(downloadInfo);
}
@Override
public void requestFileAccess(final long callbackId) {
if (mTab == null) {
// TODO(tedchoc): Show toast (only when activity is alive).
DownloadController.getInstance().onRequestFileAccessResult(callbackId, false);
return;
}
final String storagePermission = android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
final Activity activity = mTab.getWindowAndroid().getActivity().get();
if (activity == null) {
DownloadController.getInstance().onRequestFileAccessResult(callbackId, false);
} else if (mTab.getWindowAndroid().canRequestPermission(storagePermission)) {
View view = activity.getLayoutInflater().inflate(
R.layout.update_permissions_dialog, null);
TextView dialogText = (TextView) view.findViewById(R.id.text);
dialogText.setText(R.string.missing_storage_permission_download_education_text);
final PermissionCallback permissionCallback = new PermissionCallback() {
@Override
public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
DownloadController.getInstance().onRequestFileAccessResult(callbackId,
grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED);
}
};
AlertDialog.Builder builder =
new AlertDialog.Builder(activity, R.style.AlertDialogTheme)
.setView(view)
.setPositiveButton(R.string.infobar_update_permissions_button_text,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int id) {
if (mTab == null) {
dialog.cancel();
return;
}
mTab.getWindowAndroid().requestPermissions(
new String[] {storagePermission}, permissionCallback);
}
})
.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
DownloadController.getInstance().onRequestFileAccessResult(
callbackId, false);
}
});
builder.create().show();
} else if (!mTab.getWindowAndroid().isPermissionRevokedByPolicy(storagePermission)) {
nativeLaunchPermissionUpdateInfoBar(mTab, storagePermission, callbackId);
} else {
// TODO(tedchoc): Show toast.
DownloadController.getInstance().onRequestFileAccessResult(callbackId, false);
}
}
/**
* Return a pair of directory name and its full path. Note that we create the directory if
* it does not already exist.
*
* @return A pair of directory name and its full path. A pair of <null, null> will be returned
* in case of an error.
*/
private static Pair<String, String> getDownloadDirectoryNameAndFullPath() {
assert !ThreadUtils.runningOnUiThread();
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (!dir.mkdir() && !dir.isDirectory()) return new Pair<>(null, null);
String dirName = dir.getName();
String fullDirPath = dir.getPath();
return new Pair<>(dirName, fullDirPath);
}
private static boolean doesFileAlreadyExists(String dirPath, final String fileName) {
assert !ThreadUtils.runningOnUiThread();
final File file = new File(dirPath, fileName);
return file != null && file.exists();
}
private static void deleteFileForOverwrite(DownloadInfo info) {
assert !ThreadUtils.runningOnUiThread();
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (!dir.isDirectory()) return;
final File file = new File(dir, info.getFileName());
if (!file.delete()) {
Log.e(TAG, "Failed to delete a file: " + info.getFileName());
}
}
/**
* Enqueue download manager request, only from native side. Note that at this point
* we don't need to show an infobar even when the file already exists.
*
* @param overwrite Whether or not we will overwrite the file.
* @param downloadInfo The download info.
* @return true iff this request resulted in the tab creating the download to close.
*/
@CalledByNative
private boolean enqueueDownloadManagerRequestFromNative(
final boolean overwrite, final DownloadInfo downloadInfo) {
if (overwrite) {
// Android DownloadManager does not have an overwriting option.
// We remove the file here instead.
new AsyncTask<Void, Void, Void>() {
@Override
public Void doInBackground(Void... params) {
deleteFileForOverwrite(downloadInfo);
return null;
}
@Override
public void onPostExecute(Void args) {
enqueueDownloadManagerRequest(downloadInfo);
}
}.execute();
} else {
enqueueDownloadManagerRequest(downloadInfo);
}
return closeBlankTab();
}
private void launchDownloadInfoBar(DownloadInfo info, String dirName, String fullDirPath) {
if (mTab == null) return;
nativeLaunchDownloadOverwriteInfoBar(
ChromeDownloadDelegate.this, mTab, info, info.getFileName(), dirName, fullDirPath);
}
/**
* Enqueue a request to download a file using Android DownloadManager.
*
* @param info Download information about the download.
*/
private void enqueueDownloadManagerRequest(final DownloadInfo info) {
DownloadManagerService.getDownloadManagerService(
mContext.getApplicationContext()).enqueueDownloadManagerRequest(info, true);
closeBlankTab();
}
/**
* Check the external storage and notify user on error.
*
* @param fullDirPath The dir path to download a file. Normally this is external storage.
* @param externalStorageStatus The status of the external storage.
* @return Whether external storage is ok for downloading.
*/
private boolean checkExternalStorageAndNotify(
String filename, String fullDirPath, String externalStorageStatus) {
if (fullDirPath == null) {
Log.e(TAG, "Download failed: no SD card");
alertDownloadFailure(
filename, DownloadManager.ERROR_DEVICE_NOT_FOUND);
return false;
}
if (!externalStorageStatus.equals(Environment.MEDIA_MOUNTED)) {
int reason = DownloadManager.ERROR_DEVICE_NOT_FOUND;
// Check to see if the SDCard is busy, same as the music app
if (externalStorageStatus.equals(Environment.MEDIA_SHARED)) {
Log.e(TAG, "Download failed: SD card unavailable");
reason = DownloadManager.ERROR_FILE_ERROR;
} else {
Log.e(TAG, "Download failed: no SD card");
}
alertDownloadFailure(filename, reason);
return false;
}
return true;
}
/**
* Alerts user of download failure.
*
* @param fileName Name of the download file.
* @param reason Reason of failure defined in {@link DownloadManager}
*/
private void alertDownloadFailure(String fileName, int reason) {
DownloadManagerService.getDownloadManagerService(
mContext.getApplicationContext()).onDownloadFailed(fileName, reason);
}
/**
* Called when download starts.
*
* @param filename Name of the file.
* @param mimeType MIME type of the content.
*/
@Override
public void onDownloadStarted(String filename, String mimeType) {
if (!isDangerousFile(filename, mimeType)) {
showDownloadStartNotification();
closeBlankTab();
}
}
/**
* Shows the download started notification.
*/
private void showDownloadStartNotification() {
Toast.makeText(mContext, R.string.download_pending, Toast.LENGTH_SHORT).show();
}
/**
* If the given MIME type is null, or one of the "generic" types (text/plain
* or application/octet-stream) map it to a type that Android can deal with.
* If the given type is not generic, return it unchanged.
*
* We have to implement this ourselves as
* MimeTypeMap.remapGenericMimeType() is not public.
* See http://crbug.com/407829.
*
* @param mimeType MIME type provided by the server.
* @param url URL of the data being loaded.
* @param filename file name obtained from content disposition header
* @return The MIME type that should be used for this data.
*/
private static String remapGenericMimeType(String mimeType, String url, String filename) {
// If we have one of "generic" MIME types, try to deduce
// the right MIME type from the file extension (if any):
if (mimeType == null || mimeType.isEmpty() || "text/plain".equals(mimeType)
|| "application/octet-stream".equals(mimeType)
|| "octet/stream".equals(mimeType)
|| "application/force-download".equals(mimeType)) {
if (!TextUtils.isEmpty(filename)) {
url = filename;
}
String extension = MimeTypeMap.getFileExtensionFromUrl(url);
String newMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (newMimeType != null) {
mimeType = newMimeType;
} else if (extension.equals("dm")) {
mimeType = OMADownloadHandler.OMA_DRM_MESSAGE_MIME;
} else if (extension.equals("dd")) {
mimeType = OMADownloadHandler.OMA_DOWNLOAD_DESCRIPTOR_MIME;
}
}
return mimeType;
}
/**
* Check whether a file is dangerous.
*
* @param filename Name of the file.
* @param mimeType MIME type of the content.
* @return true if the file is dangerous, or false otherwise.
*/
protected boolean isDangerousFile(String filename, String mimeType) {
return nativeIsDownloadDangerous(filename) || isDangerousExtension(
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType));
}
/**
* Check whether a file extension is dangerous.
*
* @param ext Extension of the file.
* @return true if the file is dangerous, or false otherwise.
*/
private static boolean isDangerousExtension(String ext) {
return "apk".equals(ext);
}
/**
* Discards a downloaded file.
*
* @param filepath File to be discarded.
*/
private static void discardFile(final String filepath) {
new AsyncTask<Void, Void, Void>() {
@Override
public Void doInBackground(Void... params) {
Log.d(TAG, "Discarding download: " + filepath);
File file = new File(filepath);
if (file.exists() && !file.delete()) {
Log.e(TAG, "Error discarding file: " + filepath);
}
return null;
}
}.execute();
}
/**
* Close a blank tab just opened for the download purpose.
* @return true iff the tab was (already) closed.
*/
private boolean closeBlankTab() {
if (mTab == null) {
// We do not want caller to dismiss infobar.
return true;
}
WebContents contents = mTab.getWebContents();
boolean isInitialNavigation = contents == null
|| contents.getNavigationController().isInitialNavigation();
if (isInitialNavigation) {
// Tab is created just for download, close it.
return mTabModelSelector.closeTab(mTab);
}
return false;
}
/**
* For certain download types(OMA for example), android DownloadManager should
* handle them. Call this function to intercept those downloads.
*
* @param url URL to be downloaded.
* @return whether the DownloadManager should intercept the download.
*/
public boolean shouldInterceptContextMenuDownload(String url) {
Uri uri = Uri.parse(url);
String scheme = uri.normalizeScheme().getScheme();
if (!"http".equals(scheme) && !"https".equals(scheme)) return false;
String path = uri.getPath();
// OMA downloads have extension "dm" or "dd". For the latter, it
// can be handled when native download completes.
if (path != null && (path.endsWith(".dm"))) {
final DownloadInfo downloadInfo = new DownloadInfo.Builder().setUrl(url).build();
if (mTab == null) return true;
WindowAndroid window = mTab.getWindowAndroid();
if (window.hasPermission(permission.WRITE_EXTERNAL_STORAGE)) {
onDownloadStartNoStream(downloadInfo);
} else if (window.canRequestPermission(permission.WRITE_EXTERNAL_STORAGE)) {
PermissionCallback permissionCallback = new PermissionCallback() {
@Override
public void onRequestPermissionsResult(
String[] permissions, int[] grantResults) {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
onDownloadStartNoStream(downloadInfo);
}
}
};
window.requestPermissions(
new String[] {permission.WRITE_EXTERNAL_STORAGE}, permissionCallback);
}
return true;
}
return false;
}
protected Context getContext() {
return mContext;
}
private static native String nativeGetDownloadWarningText(String filename);
private static native boolean nativeIsDownloadDangerous(String filename);
private static native void nativeDangerousDownloadValidated(
Object tab, int downloadId, boolean accept);
private static native void nativeLaunchDownloadOverwriteInfoBar(ChromeDownloadDelegate delegate,
Tab tab, DownloadInfo downloadInfo, String fileName, String dirName,
String dirFullPath);
private static native void nativeLaunchPermissionUpdateInfoBar(
Tab tab, String permission, long callbackId);
}