| // Copyright 2019 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.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.Environment; |
| import android.support.v4.app.NotificationManagerCompat; |
| import android.text.TextUtils; |
| import android.util.Pair; |
| |
| import org.chromium.base.Callback; |
| import org.chromium.base.ContentUriUtils; |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.Log; |
| import org.chromium.base.StrictModeContext; |
| import org.chromium.base.ThreadUtils; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.task.AsyncTask; |
| |
| import java.io.File; |
| import java.lang.reflect.InvocationTargetException; |
| import java.lang.reflect.Method; |
| 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 long INVALID_SYSTEM_DOWNLOAD_ID = -1; |
| 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 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 = 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, String originalUrl, String referer, |
| String downloadGuid) { |
| assert !ThreadUtils.runningOnUiThread(); |
| DownloadManager manager = |
| (DownloadManager) getContext().getSystemService(Context.DOWNLOAD_SERVICE); |
| NotificationManagerCompat notificationManager = |
| NotificationManagerCompat.from(getContext()); |
| boolean useSystemNotification = !notificationManager.areNotificationsEnabled(); |
| long downloadId = getDownloadIdForDownloadGuid(downloadGuid); |
| if (downloadId != DownloadItem.INVALID_DOWNLOAD_ID) return downloadId; |
| |
| if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { |
| Class<?> c = manager.getClass(); |
| try { |
| Class[] args = {String.class, String.class, boolean.class, String.class, |
| String.class, long.class, boolean.class, Uri.class, Uri.class}; |
| Method method = c.getMethod("addCompletedDownload", args); |
| // OriginalUri has to be null or non-empty http(s) scheme. |
| Uri originalUri = DownloadUtils.parseOriginalUrl(originalUrl); |
| Uri refererUri = TextUtils.isEmpty(referer) ? null : Uri.parse(referer); |
| downloadId = (Long) method.invoke(manager, fileName, description, true, mimeType, |
| filePath, fileSizeBytes, useSystemNotification, originalUri, refererUri); |
| } catch (SecurityException e) { |
| Log.e(TAG, "Cannot access the needed method."); |
| } catch (NoSuchMethodException e) { |
| Log.e(TAG, "Cannot find the needed method."); |
| } catch (InvocationTargetException e) { |
| Log.e(TAG, "Error calling the needed method."); |
| } catch (IllegalAccessException e) { |
| Log.e(TAG, "Error accessing the needed method."); |
| } |
| } else { |
| downloadId = manager.addCompletedDownload(fileName, description, true, mimeType, |
| filePath, fileSizeBytes, useSystemNotification); |
| } |
| 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) { |
| 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 != 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); |
| Cursor c = manager.query(new DownloadManager.Query().setFilterById(downloadId)); |
| if (c == null) { |
| result.downloadStatus = DownloadManagerService.DownloadStatus.CANCELLED; |
| return result; |
| } |
| result.downloadStatus = DownloadManagerService.DownloadStatus.IN_PROGRESS; |
| if (c.moveToNext()) { |
| int status = c.getInt(c.getColumnIndex(DownloadManager.COLUMN_STATUS)); |
| result.downloadStatus = getDownloadStatus(status); |
| result.fileName = c.getString(c.getColumnIndex(DownloadManager.COLUMN_TITLE)); |
| result.failureReason = c.getInt(c.getColumnIndex(DownloadManager.COLUMN_REASON)); |
| result.lastModifiedTime = |
| c.getLong(c.getColumnIndex(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP)); |
| result.bytesDownloaded = |
| c.getLong(c.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); |
| result.bytesTotal = |
| c.getLong(c.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); |
| } else { |
| result.downloadStatus = DownloadManagerService.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); |
| |
| return result; |
| } |
| |
| /** @return The android DownloadManager's download ID for the given download. */ |
| public static long getDownloadIdForDownloadGuid(String downloadGuid) { |
| try (StrictModeContext unused = StrictModeContext.allowDiskReads()) { |
| return getSharedPreferences().getLong(downloadGuid, 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 = INVALID_SYSTEM_DOWNLOAD_ID; |
| synchronized (sLock) { |
| SharedPreferences sharedPrefs = getSharedPreferences(); |
| downloadId = sharedPrefs.getLong(downloadGuid, INVALID_SYSTEM_DOWNLOAD_ID); |
| if (downloadId != 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 and determine if the download can be resolved to any |
| * activity so that it can be auto-opened. |
| */ |
| @CalledByNative |
| private static void addCompletedDownload(String fileName, String description, |
| String originalMimeType, String filePath, long fileSizeBytes, String originalUrl, |
| String referrer, String downloadGuid, long callbackId) { |
| final String mimeType = |
| DownloadUtils.remapGenericMimeType(originalMimeType, originalUrl, fileName); |
| AsyncTask<Pair<Long, Boolean>> task = new AsyncTask<Pair<Long, Boolean>>() { |
| @Override |
| protected Pair<Long, Boolean> doInBackground() { |
| long downloadId = ContentUriUtils.isContentUri(filePath) |
| ? DownloadItem.INVALID_DOWNLOAD_ID |
| : addCompletedDownload(fileName, description, mimeType, filePath, |
| fileSizeBytes, originalUrl, referrer, downloadGuid); |
| boolean success = ContentUriUtils.isContentUri(filePath) |
| || downloadId != DownloadItem.INVALID_DOWNLOAD_ID; |
| boolean canResolve = success |
| && DownloadManagerService.canResolveDownload( |
| filePath, mimeType, downloadId); |
| return Pair.create(downloadId, canResolve); |
| } |
| |
| @Override |
| protected void onPostExecute(Pair<Long, Boolean> result) { |
| nativeOnAddCompletedDownloadDone(callbackId, result.first, result.second); |
| } |
| }; |
| 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."); |
| nativeOnAddCompletedDownloadDone(callbackId, DownloadItem.INVALID_DOWNLOAD_ID, false); |
| } |
| } |
| |
| private static int getDownloadStatus(int downloadManagerStatus) { |
| switch (downloadManagerStatus) { |
| case DownloadManager.STATUS_SUCCESSFUL: |
| return DownloadManagerService.DownloadStatus.COMPLETE; |
| case DownloadManager.STATUS_FAILED: |
| return DownloadManagerService.DownloadStatus.FAILED; |
| default: |
| return DownloadManagerService.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); |
| } |
| } |
| |
| private static native void nativeOnAddCompletedDownloadDone( |
| long callbackId, long downloadId, boolean canResolve); |
| } |