// 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.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.webkit.URLUtil;
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.ChromeActivity;
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_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.PermissionCallback;
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 {
private static final String TAG = "Download";
// The application context.
private final Context mContext;
private Tab mTab;
/**
* Creates ChromeDownloadDelegate.
* @param context The application context.
* @param tab The corresponding tab instance.
*/
public ChromeDownloadDelegate(Context context, Tab tab) {
mContext = context;
mTab = tab;
mTab.addObserver(new EmptyTabObserver() {
@Override
public void onDestroyed(Tab tab) {
mTab = null;
}
});
nativeInit(tab.getWebContents());
}
/**
* 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;
// 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();
}
@CalledByNative
private void requestFileAccess(final long callbackId) {
if (mTab == null || mTab.getWindowAndroid() == 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(
new DownloadItem(true, 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.
*/
@CalledByNative
private void onDownloadStarted(String filename) {
if (!isDangerousFile(filename)) {
DownloadUtils.showDownloadStartToast(mContext);
closeBlankTab();
}
}
/**
* 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.
*/
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)
|| "binary/octet-stream".equals(mimeType)
|| "octet/stream".equals(mimeType)
|| "application/force-download".equals(mimeType)
|| "application/unknown".equals(mimeType)) {
String extension = getFileExtension(url, filename);
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;
}
/**
* Retrieve the file extension from a given file name or url.
*
* @param url URL to extract the extension.
* @param filename File name to extract the extension.
* @return If extension can be extracted from file name, use that. Or otherwise, use the
* extension extracted from the url.
*/
static String getFileExtension(String url, String filename) {
if (!TextUtils.isEmpty(filename)) {
int index = filename.lastIndexOf(".");
if (index > 0) return filename.substring(index + 1);
}
return MimeTypeMap.getFileExtensionFromUrl(url);
}
/**
* Check whether a file is dangerous.
*
* @param filename Name of the file.
* @return true if the file is dangerous, or false otherwise.
*/
protected boolean isDangerousFile(String filename) {
return nativeIsDownloadDangerous(filename);
}
/**
* 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.
Activity activity = mTab.getWindowAndroid().getActivity().get();
if (!(activity instanceof ChromeActivity)) return true;
TabModelSelector selector = ((ChromeActivity) activity).getTabModelSelector();
return selector == null ? true : selector.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")) return false;
if (mTab == null) return true;
String fileName = URLUtil.guessFileName(
url, null, OMADownloadHandler.OMA_DRM_MESSAGE_MIME);
final DownloadInfo downloadInfo =
new DownloadInfo.Builder().setUrl(url).setFileName(fileName).build();
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;
}
protected Context getContext() {
return mContext;
}
private native void nativeInit(WebContents webContents);
private static native String nativeGetDownloadWarningText(String filename);
private static native boolean nativeIsDownloadDangerous(String filename);
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);
}