// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.download;
import android.app.DownloadManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Environment;
import android.text.TextUtils;
import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;
import org.chromium.base.Callback;
import org.chromium.base.ContentUriUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.browser_ui.util.DownloadUtils;
import org.chromium.url.GURL;
import java.io.File;
import java.util.concurrent.RejectedExecutionException;
/** A wrapper for Android DownloadManager to provide utility functions. */
public class DownloadManagerBridge {
private static final String TAG = "DownloadDelegate";
private static final String DOWNLOAD_DIRECTORY = "Download";
private static final String DOWNLOAD_ID_MAPPINGS_FILE_NAME = "download_id_mappings";
private static final Object sLock = new Object();
/** Result for querying the Android DownloadManager. */
public static class DownloadQueryResult {
public final long downloadId;
public int downloadStatus;
public String fileName;
public String mimeType;
public Uri contentUri;
public long lastModifiedTime;
public long bytesDownloaded;
public long bytesTotal;
public int failureReason;
public String filePath;
public DownloadQueryResult(long downloadId) {
this.downloadId = downloadId;
}
}
/**
* Contains the request params associated with a call to {@link
* DownloadManagerBridge.enqueueNewDownload}.
*/
public static class DownloadEnqueueRequest {
public String url;
public String fileName;
public String description;
public String mimeType;
public String cookie;
public String referrer;
public String userAgent;
public boolean notifyCompleted;
}
/** Contains the results from the call to {@link DownloadManagerBridge.enqueueNewDownload}. */
public static class DownloadEnqueueResponse {
public long downloadId = DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID;
public boolean result;
public int failureReason;
public long startTime;
}
/**
* Adds a download to the Android DownloadManager.
* @see android.app.DownloadManager#addCompletedDownload(String, String, boolean, String,
* String, long, boolean)
*/
public static long addCompletedDownload(
String fileName,
String description,
String mimeType,
String filePath,
long fileSizeBytes,
GURL originalUrl,
GURL referer,
String downloadGuid) {
assert !ThreadUtils.runningOnUiThread();
assert VERSION.SDK_INT < VERSION_CODES.Q
: "addCompletedDownload is deprecated in Q, may cause crash.";
long downloadId = getDownloadIdForDownloadGuid(downloadGuid);
if (downloadId != DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID) return downloadId;
downloadId =
DownloadUtils.addCompletedDownload(
fileName,
description,
mimeType,
filePath,
fileSizeBytes,
originalUrl,
referer);
if (downloadId != DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID) {
addDownloadIdMapping(downloadId, downloadGuid);
}
return downloadId;
}
/**
* Removes a download from Android DownloadManager.
* @param downloadGuid The GUID of the download.
* @param externallyRemoved If download is externally removed in other application.
*/
@CalledByNative
public static void removeCompletedDownload(String downloadGuid, boolean externallyRemoved) {
PostTask.postTask(
TaskTraits.BEST_EFFORT_MAY_BLOCK,
() -> {
long downloadId = removeDownloadIdMapping(downloadGuid);
// Let Android DownloadManager to remove download only if the user removed the
// file in Chrome. If the user renamed or moved the file, Chrome should keep
// it intact.
if (downloadId != DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID
&& !externallyRemoved) {
DownloadManager manager =
(DownloadManager)
getContext().getSystemService(Context.DOWNLOAD_SERVICE);
manager.remove(downloadId);
}
});
}
/**
* Sends the download request to Android download manager. If |notifyCompleted| is true,
* a notification will be sent to the user once download is complete and the downloaded
* content will be saved to the public directory on external storage. Otherwise, the
* download will be saved in the app directory and user will not get any notifications
* after download completion.
* This will be used by OMA downloads as we need Android DownloadManager to encrypt the content.
*
* @param request The download request params.
* @param callback The callback to be executed after the download request is enqueued.
*/
public static void enqueueNewDownload(
DownloadEnqueueRequest request, Callback<DownloadEnqueueResponse> callback) {
new EnqueueNewDownloadTask(request, callback)
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Query the Android DownloadManager for download status.
* @param downloadId The id of the download.
* @param callback Callback to be notified when query completes.
*/
public static void queryDownloadResult(
long downloadId, Callback<DownloadQueryResult> callback) {
DownloadQueryTask task = new DownloadQueryTask(downloadId, callback);
task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Query the Android DownloadManager for download status.
* @param downloadId The id of the download.
*/
public static DownloadQueryResult queryDownloadResult(long downloadId) {
assert !ThreadUtils.runningOnUiThread();
DownloadQueryResult result = new DownloadQueryResult(downloadId);
DownloadManager manager =
(DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
try {
Cursor c = manager.query(new DownloadManager.Query().setFilterById(downloadId));
if (c == null) {
result.downloadStatus = DownloadStatus.CANCELLED;
return result;
}
result.downloadStatus = DownloadStatus.IN_PROGRESS;
if (c.moveToNext()) {
int status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
result.downloadStatus = getDownloadStatus(status);
result.fileName =
c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE));
result.failureReason =
c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON));
result.lastModifiedTime =
c.getLong(
c.getColumnIndexOrThrow(
DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP));
result.bytesDownloaded =
c.getLong(
c.getColumnIndexOrThrow(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
result.bytesTotal =
c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
String localUri =
c.getString(c.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI));
if (!TextUtils.isEmpty(localUri)) {
Uri uri = Uri.parse(localUri);
result.filePath = uri.getPath();
}
} else {
result.downloadStatus = DownloadStatus.CANCELLED;
}
c.close();
try {
result.contentUri = manager.getUriForDownloadedFile(downloadId);
} catch (SecurityException e) {
Log.e(TAG, "unable to get content URI from DownloadManager");
}
result.mimeType = manager.getMimeTypeForDownloadedFile(downloadId);
} catch (Exception e) {
result.downloadStatus = DownloadStatus.CANCELLED;
Log.e(TAG, "unable to query android DownloadManager", e);
}
return result;
}
/**
* @return The android DownloadManager's download ID for the given download.
*/
public static long getDownloadIdForDownloadGuid(String downloadGuid) {
return getSharedPreferences()
.getLong(downloadGuid, DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID);
}
/**
* Inserts a new download ID mapping into the SharedPreferences
*
* @param downloadId system download ID from Android DownloadManager.
* @param downloadGuid Download GUID.
*/
private static void addDownloadIdMapping(long downloadId, String downloadGuid) {
synchronized (sLock) {
SharedPreferences sharedPrefs = getSharedPreferences();
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.putLong(downloadGuid, downloadId);
editor.apply();
}
}
/**
* Removes a download Id mapping from the SharedPreferences given the download GUID.
* @param downloadGuid Download GUID.
* @return the Android DownloadManager's download ID that is removed, or
* INVALID_SYSTEM_DOWNLOAD_ID if it is not found.
*/
private static long removeDownloadIdMapping(String downloadGuid) {
long downloadId = DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID;
synchronized (sLock) {
SharedPreferences sharedPrefs = getSharedPreferences();
downloadId =
sharedPrefs.getLong(downloadGuid, DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID);
if (downloadId != DownloadUtils.INVALID_SYSTEM_DOWNLOAD_ID) {
SharedPreferences.Editor editor = sharedPrefs.edit();
editor.remove(downloadGuid);
editor.apply();
}
}
return downloadId;
}
/**
* Lazily retrieve the SharedPreferences when needed. Since download operations are not very
* frequent, no need to load all SharedPreference entries into a hashmap in the memory.
* @return the SharedPreferences instance.
*/
private static SharedPreferences getSharedPreferences() {
return ContextUtils.getApplicationContext()
.getSharedPreferences(DOWNLOAD_ID_MAPPINGS_FILE_NAME, Context.MODE_PRIVATE);
}
private static Context getContext() {
return ContextUtils.getApplicationContext();
}
/**
* This function is meant to be called as the last step of a download. It will add the download
* to the android's DownloadManager if the download is not a content URI.
*/
@CalledByNative
private static void addCompletedDownload(
String fileName,
String description,
String originalMimeType,
String filePath,
long fileSizeBytes,
GURL originalUrl,
GURL referrer,
String downloadGuid,
long callbackId) {
final String mimeType =
MimeUtils.remapGenericMimeType(originalMimeType, originalUrl.getSpec(), fileName);
AsyncTask<Long> task =
new AsyncTask<Long>() {
@Override
protected Long doInBackground() {
long downloadId = DownloadConstants.INVALID_DOWNLOAD_ID;
// On Android Q-, add the completed download to Android download manager.
if (!ContentUriUtils.isContentUri(filePath)
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
downloadId =
addCompletedDownload(
fileName,
description,
mimeType,
filePath,
fileSizeBytes,
originalUrl,
referrer,
downloadGuid);
}
return downloadId;
}
@Override
protected void onPostExecute(Long downloadId) {
DownloadManagerBridgeJni.get()
.onAddCompletedDownloadDone(callbackId, downloadId);
}
};
try {
task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} catch (RejectedExecutionException e) {
// Reaching thread limit, update will be reschduled for the next run.
Log.e(TAG, "Thread limit reached, reschedule notification update later.");
DownloadManagerBridgeJni.get()
.onAddCompletedDownloadDone(callbackId, DownloadConstants.INVALID_DOWNLOAD_ID);
}
}
private static int getDownloadStatus(int downloadManagerStatus) {
switch (downloadManagerStatus) {
case DownloadManager.STATUS_SUCCESSFUL:
return DownloadStatus.COMPLETE;
case DownloadManager.STATUS_FAILED:
return DownloadStatus.FAILED;
default:
return DownloadStatus.IN_PROGRESS;
}
}
/** Async task to query download status from Android DownloadManager */
private static class DownloadQueryTask extends AsyncTask<DownloadQueryResult> {
private final long mDownloadId;
private final Callback<DownloadQueryResult> mCallback;
public DownloadQueryTask(long downloadId, Callback<DownloadQueryResult> callback) {
mDownloadId = downloadId;
mCallback = callback;
}
@Override
public DownloadQueryResult doInBackground() {
return queryDownloadResult(mDownloadId);
}
@Override
protected void onPostExecute(DownloadQueryResult result) {
mCallback.onResult(result);
}
}
/** Async task to enqueue a download request into DownloadManager. */
private static class EnqueueNewDownloadTask extends AsyncTask<Boolean> {
private final DownloadEnqueueRequest mEnqueueRequest;
private final Callback<DownloadEnqueueResponse> mCallback;
private long mDownloadId;
private int mFailureReason;
private long mStartTime;
public EnqueueNewDownloadTask(
DownloadEnqueueRequest enqueueRequest, Callback<DownloadEnqueueResponse> callback) {
mEnqueueRequest = enqueueRequest;
mCallback = callback;
}
@Override
public Boolean doInBackground() {
DownloadManager.Request request;
try {
request = new DownloadManager.Request(Uri.parse(mEnqueueRequest.url));
} catch (IllegalArgumentException e) {
Log.e(TAG, "Cannot download non http or https scheme");
// Use ERROR_UNHANDLED_HTTP_CODE so that it will be treated as a server error.
mFailureReason = DownloadManager.ERROR_UNHANDLED_HTTP_CODE;
return false;
}
request.setMimeType(mEnqueueRequest.mimeType);
try {
if (mEnqueueRequest.notifyCompleted) {
if (mEnqueueRequest.fileName != null) {
// Set downloaded file destination to /sdcard/Download or, should it be
// set to one of several Environment.DIRECTORY* dirs depending on mimetype?
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, mEnqueueRequest.fileName);
}
} else {
File dir = new File(getContext().getExternalFilesDir(null), DOWNLOAD_DIRECTORY);
if (dir.mkdir() || dir.isDirectory()) {
File file = new File(dir, mEnqueueRequest.fileName);
request.setDestinationUri(Uri.fromFile(file));
} else {
Log.e(TAG, "Cannot create download directory");
mFailureReason = DownloadManager.ERROR_FILE_ERROR;
return false;
}
}
} catch (IllegalStateException e) {
Log.e(TAG, "Cannot create download directory");
mFailureReason = DownloadManager.ERROR_FILE_ERROR;
return false;
}
if (mEnqueueRequest.notifyCompleted) {
// Let this downloaded file be scanned by MediaScanner - so that it can
// show up in Gallery app, for example.
request.allowScanningByMediaScanner();
request.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
} else {
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE);
}
String description = mEnqueueRequest.description;
if (TextUtils.isEmpty(description)) {
description = mEnqueueRequest.fileName;
}
request.setDescription(description);
request.setTitle(mEnqueueRequest.fileName);
request.addRequestHeader("Cookie", mEnqueueRequest.cookie);
request.addRequestHeader("referrer", mEnqueueRequest.referrer);
request.addRequestHeader("User-Agent", mEnqueueRequest.userAgent);
DownloadManager manager =
(DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE);
try {
mStartTime = System.currentTimeMillis();
mDownloadId = manager.enqueue(request);
} catch (IllegalArgumentException e) {
// See crbug.com/143499 for more details.
Log.e(TAG, "Download failed: " + e);
mFailureReason = DownloadManager.ERROR_UNKNOWN;
return false;
} catch (RuntimeException e) {
// See crbug.com/490442 for more details.
Log.e(TAG, "Failed to create target file on the external storage: " + e);
mFailureReason = DownloadManager.ERROR_FILE_ERROR;
return false;
}
return true;
}
@Override
protected void onPostExecute(Boolean result) {
DownloadEnqueueResponse enqueueResult = new DownloadEnqueueResponse();
enqueueResult.result = result;
enqueueResult.failureReason = mFailureReason;
enqueueResult.downloadId = mDownloadId;
enqueueResult.startTime = mStartTime;
mCallback.onResult(enqueueResult);
}
}
@NativeMethods
interface Natives {
void onAddCompletedDownloadDone(long callbackId, long downloadId);
}
}