| // 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 static android.app.DownloadManager.ACTION_NOTIFICATION_CLICKED; |
| |
| import android.annotation.SuppressLint; |
| import android.annotation.TargetApi; |
| import android.app.DownloadManager; |
| import android.app.Notification; |
| import android.app.NotificationManager; |
| import android.app.PendingIntent; |
| import android.app.Service; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.SharedPreferences; |
| import android.content.res.Resources; |
| import android.graphics.Bitmap; |
| import android.graphics.BitmapFactory; |
| import android.graphics.Canvas; |
| import android.graphics.Paint; |
| import android.graphics.Rect; |
| import android.graphics.drawable.shapes.OvalShape; |
| import android.net.Uri; |
| import android.os.Binder; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.IBinder; |
| import android.service.notification.StatusBarNotification; |
| import android.support.v4.app.NotificationCompat; |
| import android.text.TextUtils; |
| import android.util.Pair; |
| |
| import org.chromium.base.ApiCompatibilityUtils; |
| import org.chromium.base.ApplicationStatus; |
| import org.chromium.base.ContextUtils; |
| import org.chromium.base.Log; |
| import org.chromium.base.ObserverList; |
| import org.chromium.base.StrictModeContext; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.base.library_loader.LibraryProcessType; |
| import org.chromium.base.library_loader.ProcessInitException; |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.AppHooks; |
| import org.chromium.chrome.browser.ChromeApplication; |
| import org.chromium.chrome.browser.download.items.OfflineContentAggregatorNotificationBridgeUiFactory; |
| import org.chromium.chrome.browser.init.BrowserParts; |
| import org.chromium.chrome.browser.init.ChromeBrowserInitializer; |
| import org.chromium.chrome.browser.init.EmptyBrowserParts; |
| import org.chromium.chrome.browser.media.MediaViewerUtils; |
| import org.chromium.chrome.browser.notifications.ChromeNotificationBuilder; |
| import org.chromium.chrome.browser.notifications.NotificationBuilderFactory; |
| import org.chromium.chrome.browser.notifications.NotificationConstants; |
| import org.chromium.chrome.browser.notifications.NotificationUmaTracker; |
| import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions; |
| import org.chromium.chrome.browser.profiles.Profile; |
| import org.chromium.chrome.browser.util.IntentUtils; |
| import org.chromium.components.offline_items_collection.ContentId; |
| import org.chromium.components.offline_items_collection.LegacyHelpers; |
| import org.chromium.components.offline_items_collection.OfflineItem.Progress; |
| import org.chromium.content_public.browser.BrowserStartupController; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Service responsible for creating and updating download notifications even after |
| * Chrome gets killed. |
| * |
| * On O and above, this service will receive {@link Service#startForeground(int, Notification)} |
| * calls when containing active downloads. The foreground notification will be the summary |
| * notification generated by {@link DownloadNotificationService#buildSummaryNotification(Context)}. |
| * The service will receive a {@link Service#stopForeground(boolean)} call when all active downloads |
| * are paused. The summary notification will be hidden when there are no other notifications in the |
| * {@link NotificationConstants#GROUP_DOWNLOADS} group. This gets checked after every notification |
| * gets removed from the {@link NotificationManager}. |
| */ |
| public class DownloadNotificationService extends Service { |
| static final String EXTRA_DOWNLOAD_CONTENTID_ID = |
| "org.chromium.chrome.browser.download.DownloadContentId_Id"; |
| static final String EXTRA_DOWNLOAD_CONTENTID_NAMESPACE = |
| "org.chromium.chrome.browser.download.DownloadContentId_Namespace"; |
| static final String EXTRA_DOWNLOAD_FILE_PATH = "DownloadFilePath"; |
| static final String EXTRA_NOTIFICATION_DISMISSED = "NotificationDismissed"; |
| static final String EXTRA_IS_SUPPORTED_MIME_TYPE = "IsSupportedMimeType"; |
| static final String EXTRA_IS_OFF_THE_RECORD = |
| "org.chromium.chrome.browser.download.IS_OFF_THE_RECORD"; |
| |
| public static final String ACTION_DOWNLOAD_CANCEL = |
| "org.chromium.chrome.browser.download.DOWNLOAD_CANCEL"; |
| public static final String ACTION_DOWNLOAD_PAUSE = |
| "org.chromium.chrome.browser.download.DOWNLOAD_PAUSE"; |
| public static final String ACTION_DOWNLOAD_RESUME = |
| "org.chromium.chrome.browser.download.DOWNLOAD_RESUME"; |
| static final String ACTION_DOWNLOAD_RESUME_ALL = |
| "org.chromium.chrome.browser.download.DOWNLOAD_RESUME_ALL"; |
| public static final String ACTION_DOWNLOAD_OPEN = |
| "org.chromium.chrome.browser.download.DOWNLOAD_OPEN"; |
| public static final String ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON = |
| "org.chromium.chrome.browser.download.DOWNLOAD_UPDATE_SUMMARY_ICON"; |
| public static final String ACTION_DOWNLOAD_FAIL_SAFE = |
| "org.chromium.chrome.browser.download.ACTION_SUMMARY_FAIL_SAFE"; |
| |
| static final String NOTIFICATION_NAMESPACE = "DownloadNotificationService"; |
| private static final String TAG = "DownloadNotification"; |
| // Limit file name to 25 characters. TODO(qinmin): use different limit for different devices? |
| private static final int MAX_FILE_NAME_LENGTH = 25; |
| |
| /** Notification Id starting value, to avoid conflicts from IDs used in prior versions. */ |
| |
| private static final String EXTRA_NOTIFICATION_BUNDLE_ICON_ID = |
| "Chrome.NotificationBundleIconIdExtra"; |
| private static final int STARTING_NOTIFICATION_ID = 1000000; |
| private static final int MAX_RESUMPTION_ATTEMPT_LEFT = 5; |
| |
| private static final String KEY_AUTO_RESUMPTION_ATTEMPT_LEFT = "ResumptionAttemptLeft"; |
| private static final String KEY_NEXT_DOWNLOAD_NOTIFICATION_ID = "NextDownloadNotificationId"; |
| |
| /** |
| * An Observer interface that allows other classes to know when this class is canceling |
| * downloads. |
| */ |
| public interface Observer { |
| /** |
| * Called when a download was canceled from the notification. The implementer is not |
| * responsible for canceling the actual download (that should be triggered internally from |
| * this class). The implementer is responsible for using this to do their own tracking |
| * related to which downloads might be active in this service. File downloads don't trigger |
| * a cancel event when they are told to cancel downloads, so classes might have no idea that |
| * a download stopped otherwise. |
| * @param id The {@link ContentId} of the download that was canceled. |
| */ |
| void onDownloadCanceled(ContentId id); |
| } |
| |
| private final ObserverList<Observer> mObservers = new ObserverList<>(); |
| private final IBinder mBinder = new LocalBinder(); |
| private final List<ContentId> mDownloadsInProgress = new ArrayList<ContentId>(); |
| |
| private NotificationManager mNotificationManager; |
| private SharedPreferences mSharedPrefs; |
| private int mNextNotificationId; |
| private int mNumAutoResumptionAttemptLeft; |
| private Bitmap mDownloadSuccessLargeIcon; |
| private DownloadSharedPreferenceHelper mDownloadSharedPreferenceHelper; |
| |
| /** |
| * @return Whether or not this service should be made a foreground service if there are active |
| * downloads. |
| */ |
| @VisibleForTesting |
| static boolean useForegroundService() { |
| return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; |
| } |
| |
| /** |
| * Start this service with a summary {@link Notification}. This will start the service in the |
| * foreground. |
| * @param context The context used to build the notification and to start the service. |
| * @param source The {@link Intent} that should be used to build on to start the service. |
| */ |
| public static void startDownloadNotificationService(Context context, Intent source) { |
| Intent intent = source != null ? new Intent(source) : new Intent(); |
| intent.setComponent(new ComponentName(context, DownloadNotificationService.class)); |
| |
| if (useForegroundService()) { |
| NotificationManager manager = |
| (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); |
| // Attempt to update the notification summary icon without starting the service. |
| if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) { |
| // updateSummaryIcon should be a noop if the notification isn't showing or if the |
| // icon won't change anyway. |
| updateSummaryIcon(context, manager, -1, null); |
| return; |
| } |
| |
| AppHooks.get().startForegroundService(intent); |
| } else { |
| context.startService(intent); |
| } |
| } |
| |
| /** |
| * Updates the notification summary with a new icon, if necessary. |
| * @param removedNotificationId The id of a notification that is currently closing and should be |
| * ignored. -1 if no notifications are being closed. |
| * @param addedNotification A {@link Pair} of <id, Notification> of a notification that is |
| * currently being added and should be used in addition to or in |
| * place of the existing icons. |
| */ |
| private static void updateSummaryIcon(Context context, NotificationManager manager, |
| int removedNotificationId, Pair<Integer, Notification> addedNotification) { |
| if (!useForegroundService()) return; |
| |
| Pair<Boolean, Integer> icon = |
| getSummaryIcon(context, manager, removedNotificationId, addedNotification); |
| if (!icon.first || !hasDownloadNotifications(manager, removedNotificationId)) return; |
| |
| manager.notify(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, |
| buildSummaryNotificationWithIcon(context, icon.second)); |
| } |
| |
| /** |
| * Returns whether or not there are any download notifications showing that aren't the summary |
| * notification. |
| * @param notificationIdToIgnore If not -1, the id of a notification to ignore and |
| * assume is closing or about to be closed. |
| * @return Whether or not there are valid download notifications currently visible. |
| */ |
| @TargetApi(Build.VERSION_CODES.M) |
| private static boolean hasDownloadNotifications( |
| NotificationManager manager, int notificationIdToIgnore) { |
| if (!useForegroundService()) return false; |
| |
| StatusBarNotification[] notifications = manager.getActiveNotifications(); |
| for (StatusBarNotification notification : notifications) { |
| boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), |
| NotificationConstants.GROUP_DOWNLOADS); |
| boolean isSummaryNotification = |
| notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; |
| boolean isIgnoredNotification = |
| notificationIdToIgnore != -1 && notificationIdToIgnore == notification.getId(); |
| if (isDownloadsGroup && !isSummaryNotification && !isIgnoredNotification) return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Calculates the suggested icon for the summary notification based on the other notifications |
| * currently showing. |
| * @param context A context to use to query Android-specific information (NotificationManager). |
| * @param removedNotificationId The id of a notification that is currently closing and should be |
| * ignored. -1 if no notifications are being closed. |
| * @param addedNotification A {@link Pair} of <id, Notification> of a notification that is |
| * currently being added and should be used in addition to or in |
| * place of the existing icons. |
| * @return A {@link Pair} that represents both whether or not the new icon |
| * is different from the old one and the icon id itself. |
| */ |
| @TargetApi(Build.VERSION_CODES.M) |
| private static Pair<Boolean, Integer> getSummaryIcon(Context context, |
| NotificationManager manager, int removedNotificationId, |
| Pair<Integer, Notification> addedNotification) { |
| if (!useForegroundService()) return new Pair<Boolean, Integer>(false, -1); |
| boolean progress = false; |
| boolean paused = false; |
| boolean pending = false; |
| boolean completed = false; |
| boolean failed = false; |
| |
| final int progressIcon = android.R.drawable.stat_sys_download; |
| final int pausedIcon = R.drawable.ic_download_pause; |
| final int pendingIcon = R.drawable.ic_download_pending; |
| final int completedIcon = R.drawable.offline_pin; |
| final int failedIcon = android.R.drawable.stat_sys_download_done; |
| |
| StatusBarNotification[] notifications = manager.getActiveNotifications(); |
| |
| int oldIcon = -1; |
| for (StatusBarNotification notification : notifications) { |
| boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), |
| NotificationConstants.GROUP_DOWNLOADS); |
| if (!isDownloadsGroup) continue; |
| if (notification.getId() == removedNotificationId) continue; |
| |
| boolean isSummaryNotification = |
| notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; |
| |
| if (addedNotification != null && addedNotification.first == notification.getId()) { |
| continue; |
| } |
| |
| int icon = |
| notification.getNotification().extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID); |
| if (isSummaryNotification) { |
| oldIcon = icon; |
| continue; |
| } |
| |
| progress |= icon == progressIcon; |
| paused |= icon == pausedIcon; |
| pending |= icon == pendingIcon; |
| completed |= icon == completedIcon; |
| failed |= icon == failedIcon; |
| } |
| |
| if (addedNotification != null) { |
| int icon = addedNotification.second.extras.getInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID); |
| |
| progress |= icon == progressIcon; |
| paused |= icon == pausedIcon; |
| pending |= icon == pendingIcon; |
| completed |= icon == completedIcon; |
| failed |= icon == failedIcon; |
| } |
| |
| int newIcon = android.R.drawable.stat_sys_download_done; |
| if (progress) { |
| newIcon = android.R.drawable.stat_sys_download; |
| } else if (pending) { |
| newIcon = R.drawable.ic_download_pending; |
| } else if (failed) { |
| newIcon = android.R.drawable.stat_sys_download_done; |
| } else if (paused) { |
| newIcon = R.drawable.ic_download_pause; |
| } else if (completed) { |
| newIcon = R.drawable.offline_pin; |
| } |
| |
| return new Pair<Boolean, Integer>(newIcon != oldIcon, newIcon); |
| } |
| |
| /** |
| * Builds a summary notification that represents all downloads. |
| * {@see #buildSummaryNotification(Context)}. |
| * @param context A context used to query Android strings and resources. |
| * @param iconId The id of an icon to use for the notification. |
| * @return a {@link Notification} that represents the summary icon for all downloads. |
| */ |
| private static Notification buildSummaryNotificationWithIcon(Context context, int iconId) { |
| ChromeNotificationBuilder builder = |
| NotificationBuilderFactory |
| .createChromeNotificationBuilder( |
| true /* preferCompat */, ChannelDefinitions.ChannelId.DOWNLOADS) |
| .setContentTitle( |
| context.getString(R.string.download_notification_summary_title)) |
| .setSubText(context.getString(R.string.menu_downloads)) |
| .setSmallIcon(iconId) |
| .setLocalOnly(true) |
| .setGroup(NotificationConstants.GROUP_DOWNLOADS) |
| .setGroupSummary(true); |
| Bundle extras = new Bundle(); |
| extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId); |
| builder.addExtras(extras); |
| |
| // This notification should not actually be shown. But if it is, set the click intent to |
| // open downloads home. |
| // TODO(dtrainor): Only do this if we have no transient downloads. |
| Intent downloadHomeIntent = |
| buildActionIntent(context, ACTION_NOTIFICATION_CLICKED, null, false); |
| builder.setContentIntent(PendingIntent.getBroadcast(context, |
| NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, downloadHomeIntent, |
| PendingIntent.FLAG_UPDATE_CURRENT)); |
| |
| return builder.build(); |
| } |
| |
| /** |
| * Builds a summary notification that represents downloads. This is the notification passed to |
| * {@link #startForeground(int, Notification)}, which keeps this service in the foreground. |
| * @param context The context used to build the notification and pull specific resources. |
| * @return The {@link Notification} to show for the summary. Meant to be used by |
| * {@link NotificationManager#notify(int, Notification)}. |
| */ |
| private static Notification buildSummaryNotification( |
| Context context, NotificationManager manager) { |
| Pair<Boolean, Integer> icon = getSummaryIcon(context, manager, -1, null); |
| return buildSummaryNotificationWithIcon(context, icon.second); |
| } |
| |
| /** |
| * @return Whether or not there are any current resumable downloads being tracked. These |
| * tracked downloads may not currently be showing notifications. |
| */ |
| public static boolean isTrackingResumableDownloads(Context context) { |
| List<DownloadSharedPreferenceEntry> entries = |
| DownloadSharedPreferenceHelper.getInstance().getEntries(); |
| for (DownloadSharedPreferenceEntry entry : entries) { |
| if (canResumeDownload(context, entry)) return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Class for clients to access. |
| */ |
| public class LocalBinder extends Binder { |
| DownloadNotificationService getService() { |
| return DownloadNotificationService.this; |
| } |
| } |
| |
| @Override |
| public void onTaskRemoved(Intent rootIntent) { |
| // Record instance of task removed. |
| DownloadNotificationUmaHelper.recordServiceStoppedHistogram( |
| DownloadNotificationUmaHelper.ServiceStopped.TASK_REMOVED, false); |
| |
| super.onTaskRemoved(rootIntent); |
| // If we've lost all Activities, cancel the off the record downloads and validate that we |
| // should still be showing any download notifications at all. |
| if (ApplicationStatus.isEveryActivityDestroyed()) { |
| cancelOffTheRecordDownloads(); |
| hideSummaryNotificationIfNecessary(-1); |
| } |
| } |
| |
| @Override |
| public void onLowMemory() { |
| // Record instance of service with low memory. |
| DownloadNotificationUmaHelper.recordServiceStoppedHistogram( |
| DownloadNotificationUmaHelper.ServiceStopped.LOW_MEMORY, false /*withForeground*/); |
| super.onLowMemory(); |
| } |
| |
| @Override |
| public void onCreate() { |
| mNotificationManager = |
| (NotificationManager) ContextUtils.getApplicationContext().getSystemService( |
| Context.NOTIFICATION_SERVICE); |
| mSharedPrefs = ContextUtils.getAppSharedPreferences(); |
| mNumAutoResumptionAttemptLeft = |
| mSharedPrefs.getInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, MAX_RESUMPTION_ATTEMPT_LEFT); |
| mDownloadSharedPreferenceHelper = DownloadSharedPreferenceHelper.getInstance(); |
| mNextNotificationId = |
| mSharedPrefs.getInt(KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, STARTING_NOTIFICATION_ID); |
| } |
| |
| @Override |
| public void onDestroy() { |
| // Record instance of service being destroyed. |
| DownloadNotificationUmaHelper.recordServiceStoppedHistogram( |
| DownloadNotificationUmaHelper.ServiceStopped.DESTROYED, false /* withForeground */); |
| |
| updateNotificationsForShutdown(); |
| rescheduleDownloads(); |
| super.onDestroy(); |
| } |
| |
| @Override |
| public int onStartCommand(final Intent intent, int flags, int startId) { |
| // Start a foreground service every time we process a valid intent. This makes sure we |
| // honor the promise that we'll be in the foreground when we start, even if we immediately |
| // drop ourselves back. |
| if (useForegroundService() && intent != null) startForegroundInternal(); |
| |
| if (intent == null) { |
| // Record instance of service restarting. |
| DownloadNotificationUmaHelper.recordServiceStoppedHistogram( |
| DownloadNotificationUmaHelper.ServiceStopped.START_STICKY, false); |
| |
| // Intent is only null during a process restart because of returning START_STICKY. In |
| // this case cancel the off the record notifications and put the normal notifications |
| // into a pending state, then try to restart. Finally validate that we are actually |
| // showing something. |
| updateNotificationsForShutdown(); |
| handleDownloadOperation( |
| new Intent(DownloadNotificationService.ACTION_DOWNLOAD_RESUME_ALL)); |
| hideSummaryNotificationIfNecessary(-1); |
| } else if (TextUtils.equals(intent.getAction(), |
| DownloadNotificationService.ACTION_DOWNLOAD_FAIL_SAFE)) { |
| hideSummaryNotificationIfNecessary(-1); |
| } else if (isDownloadOperationIntent(intent)) { |
| handleDownloadOperation(intent); |
| DownloadResumptionScheduler.getDownloadResumptionScheduler().cancel(); |
| // Limit the number of auto resumption attempts in case Chrome falls into a vicious |
| // cycle. |
| if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) { |
| if (mNumAutoResumptionAttemptLeft > 0) { |
| mNumAutoResumptionAttemptLeft--; |
| updateResumptionAttemptLeft(); |
| } |
| } else { |
| // Reset number of attempts left if the action is triggered by user. |
| mNumAutoResumptionAttemptLeft = MAX_RESUMPTION_ATTEMPT_LEFT; |
| clearResumptionAttemptLeft(); |
| } |
| } |
| // This should restart the service after Chrome gets killed. However, this |
| // doesn't work on Android 4.4.2. |
| return START_STICKY; |
| } |
| |
| /** |
| * Adds an {@link Observer}, which will be notified when this service attempts to |
| * start stopping itself. |
| */ |
| public void addObserver(Observer observer) { |
| mObservers.addObserver(observer); |
| } |
| |
| /** |
| * Removes {@code observer}, which will no longer be notified when this class decides to start |
| * stopping itself. |
| */ |
| public void removeObserver(Observer observer) { |
| mObservers.removeObserver(observer); |
| } |
| |
| /** |
| * On >= O Android releases, puts this service into a background state. |
| * @param killNotification Whether or not this call should kill the summary notification or not. |
| * Not killing it puts the service into the background, but leaves the |
| * download notifications visible. |
| */ |
| @VisibleForTesting |
| @TargetApi(Build.VERSION_CODES.N) |
| void stopForegroundInternal(boolean killNotification) { |
| Log.w(TAG, "stopForegroundInternal killNotification: " + killNotification); |
| if (!useForegroundService()) return; |
| stopForeground(killNotification ? STOP_FOREGROUND_REMOVE : STOP_FOREGROUND_DETACH); |
| } |
| |
| /** |
| * On >= O Android releases, puts this service into a foreground state, binding it to the |
| * {@link Notification} generated by {@link #buildSummaryNotification(Context)}. |
| */ |
| @VisibleForTesting |
| void startForegroundInternal() { |
| Log.w(TAG, "startForegroundInternal"); |
| if (!useForegroundService()) return; |
| Notification notification = buildSummaryNotification( |
| ContextUtils.getApplicationContext(), mNotificationManager); |
| startForeground(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, notification); |
| } |
| |
| @VisibleForTesting |
| boolean hasDownloadNotificationsInternal(int notificationIdToIgnore) { |
| return hasDownloadNotifications(mNotificationManager, notificationIdToIgnore); |
| } |
| |
| private void rescheduleDownloads() { |
| if (mNumAutoResumptionAttemptLeft <= 0) return; |
| DownloadResumptionScheduler.getDownloadResumptionScheduler().scheduleIfNecessary(); |
| } |
| |
| @VisibleForTesting |
| void updateNotificationsForShutdown() { |
| cancelOffTheRecordDownloads(); |
| List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); |
| for (DownloadSharedPreferenceEntry entry : entries) { |
| if (entry.isOffTheRecord) continue; |
| // Move all regular downloads to pending. Don't propagate the pause because |
| // if native is still working and it triggers an update, then the service will be |
| // restarted. |
| notifyDownloadPaused(entry.id, entry.fileName, !entry.isOffTheRecord, true, |
| entry.isOffTheRecord, entry.isTransient, null); |
| } |
| } |
| |
| @VisibleForTesting |
| void cancelOffTheRecordDownloads() { |
| boolean cancelActualDownload = |
| BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER) |
| .isStartupSuccessfullyCompleted() |
| && Profile.getLastUsedProfile().hasOffTheRecordProfile(); |
| |
| List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); |
| List<DownloadSharedPreferenceEntry> copies = |
| new ArrayList<DownloadSharedPreferenceEntry>(entries); |
| for (DownloadSharedPreferenceEntry entry : copies) { |
| if (!entry.isOffTheRecord) continue; |
| ContentId id = entry.id; |
| notifyDownloadCanceled(id); |
| if (cancelActualDownload) { |
| DownloadServiceDelegate delegate = getServiceDelegate(id); |
| delegate.cancelDownload(id, true); |
| delegate.destroyServiceDelegate(); |
| } |
| for (Observer observer : mObservers) observer.onDownloadCanceled(id); |
| } |
| } |
| |
| /** |
| * Track in-progress downloads here and, if on an Android version >= O, make |
| * this a foreground service. |
| * @param id The {@link ContentId} of the download that has been started and should be tracked. |
| */ |
| private void startTrackingInProgressDownload(ContentId id) { |
| Log.w(TAG, "startTrackingInProgressDownload"); |
| if (mDownloadsInProgress.size() == 0) startForegroundInternal(); |
| if (!mDownloadsInProgress.contains(id)) mDownloadsInProgress.add(id); |
| } |
| |
| /** |
| * Stop tracking the download represented by {@code id}. If on an Android version >= O, stop |
| * making this a foreground service. |
| * @param id The {@link ContentId} of the download that has been paused or |
| * canceled and shouldn't be tracked. |
| * @param allowStopForeground Whether or not this should check internal state and stop the |
| * foreground notification from showing. This could be false if we |
| * plan on removing the notification in the near future. We don't |
| * want to just detach here, because that will put us in a |
| * potentially bad state where we cannot dismiss the notification. |
| */ |
| private void stopTrackingInProgressDownload(ContentId id, boolean allowStopForeground) { |
| Log.w(TAG, "stopTrackingInProgressDownload"); |
| mDownloadsInProgress.remove(id); |
| if (allowStopForeground && mDownloadsInProgress.size() == 0) stopForegroundInternal(false); |
| } |
| |
| /** |
| * @return The summary {@link StatusBarNotification} if one is showing. |
| */ |
| @TargetApi(Build.VERSION_CODES.M) |
| private static StatusBarNotification getSummaryNotification(NotificationManager manager) { |
| if (!useForegroundService()) return null; |
| |
| StatusBarNotification[] notifications = manager.getActiveNotifications(); |
| for (StatusBarNotification notification : notifications) { |
| boolean isDownloadsGroup = TextUtils.equals(notification.getNotification().getGroup(), |
| NotificationConstants.GROUP_DOWNLOADS); |
| boolean isSummaryNotification = |
| notification.getId() == NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY; |
| if (isDownloadsGroup && isSummaryNotification) return notification; |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Cancels the existing summary notification. Moved to a helper method for test mocking. |
| */ |
| @VisibleForTesting |
| void cancelSummaryNotification() { |
| mNotificationManager.cancel(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY); |
| } |
| |
| /** |
| * Check all current notifications and hide the summary notification if we have no downloads |
| * notifications left. On Android if the user swipes away the last download notification the |
| * summary will be dismissed. But if the last downloads notification is dismissed via |
| * {@link NotificationManager#cancel(int)}, the summary will remain, so we need to check and |
| * manually remove it ourselves. |
| * @param notificationIdToIgnore Canceling a notification and querying for the current list of |
| * active notifications isn't synchronous. Pass a notification id |
| * here if there is a notification that should be assumed gone. |
| * Or pass -1 if no notification fits that criteria. |
| */ |
| @SuppressLint("NewApi") // useForegroundService guards StatusBarNotification.getNotification |
| boolean hideSummaryNotificationIfNecessary(int notificationIdToIgnore) { |
| Log.w(TAG, "hideSummaryNotificationIfNecessary id: " + notificationIdToIgnore); |
| if (mDownloadsInProgress.size() > 0) return false; |
| |
| if (useForegroundService()) { |
| if (hasDownloadNotificationsInternal(notificationIdToIgnore)) return false; |
| |
| StatusBarNotification notification = getSummaryNotification(mNotificationManager); |
| if (notification != null) { |
| // We have a valid summary notification, but how we dismiss it depends on whether or |
| // not it is currently bound to this service via startForeground(...). |
| if ((notification.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) |
| != 0) { |
| // If we are a foreground service and we are hiding the notification, we have no |
| // other downloads notifications showing, so we need to remove the notification |
| // and unregister it from this service at the same time. |
| stopForegroundInternal(true); |
| } else { |
| // If we are not a foreground service, remove the notification via the |
| // NotificationManager. The notification is not bound to this service, so any |
| // call to stopForeground() won't affect the notification. |
| cancelSummaryNotification(); |
| } |
| } else { |
| // If we don't have a valid summary, just guarantee that we aren't in the foreground |
| // for safety. Still try to remove the summary notification to make sure it's gone. |
| // This is because querying for it might fail if we have just recently started up |
| // and began showing it. This might leave us in a bad state if the cancel request |
| // fails inside the framework. |
| // TODO(dtrainor): Add a way to attempt to automatically clean up the notification |
| // shortly after this. |
| stopForegroundInternal(true); |
| } |
| } else { |
| // If we're not using a foreground service, just shut down after we are no longer |
| // tracking any downloads. |
| if (mDownloadSharedPreferenceHelper.getEntries().size() > 0) return false; |
| } |
| |
| // Stop the service which should start the destruction process. At this point we should be |
| // a background service. We might not be unbound from any clients. When they unbind we |
| // will shut down. That is okay because they will only unbind from us when they are ok with |
| // us going away (e.g. we shouldn't be unbound while in the foreground). |
| stopSelf(); |
| |
| // Record instance of service being stopped intentionally. |
| DownloadNotificationUmaHelper.recordServiceStoppedHistogram( |
| DownloadNotificationUmaHelper.ServiceStopped.STOPPED, false /* withForeground */); |
| return true; |
| } |
| |
| @Override |
| public IBinder onBind(Intent intent) { |
| return mBinder; |
| } |
| |
| /** |
| * Helper method to update the remaining number of background resumption attempts left. |
| */ |
| private void updateResumptionAttemptLeft() { |
| SharedPreferences.Editor editor = mSharedPrefs.edit(); |
| editor.putInt(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT, mNumAutoResumptionAttemptLeft); |
| editor.apply(); |
| } |
| |
| /** |
| * Helper method to clear the remaining number of background resumption attempts left. |
| */ |
| static void clearResumptionAttemptLeft() { |
| SharedPreferences SharedPrefs = ContextUtils.getAppSharedPreferences(); |
| SharedPreferences.Editor editor = SharedPrefs.edit(); |
| editor.remove(KEY_AUTO_RESUMPTION_ATTEMPT_LEFT); |
| editor.apply(); |
| } |
| |
| /** |
| * Adds or updates an in-progress download notification. |
| * @param id The {@link ContentId} of the download. |
| * @param fileName File name of the download. |
| * @param progress The current download progress. |
| * @param bytesReceived Total number of bytes received. |
| * @param timeRemainingInMillis Remaining download time in milliseconds. |
| * @param startTime Time when download started. |
| * @param isOffTheRecord Whether the download is off the record. |
| * @param canDownloadWhileMetered Whether the download can happen in metered network. |
| * @param isTransient Whether or not clicking on the download should launch |
| * downloads home. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| */ |
| @VisibleForTesting |
| public void notifyDownloadProgress(ContentId id, String fileName, Progress progress, |
| long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord, |
| boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) { |
| updateActiveDownloadNotification(id, fileName, progress, bytesReceived, |
| timeRemainingInMillis, startTime, isOffTheRecord, canDownloadWhileMetered, false, |
| isTransient, icon); |
| } |
| |
| /** |
| * Adds or updates a pending download notification. |
| * @param id The {@link ContentId} of the download. |
| * @param fileName File name of the download. |
| * @param isOffTheRecord Whether the download is off the record. |
| * @param canDownloadWhileMetered Whether the download can happen in metered network. |
| * @param isTransient Whether or not clicking on the download should launch |
| * downloads home. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| */ |
| private void notifyDownloadPending(ContentId id, String fileName, boolean isOffTheRecord, |
| boolean canDownloadWhileMetered, boolean isTransient, Bitmap icon) { |
| updateActiveDownloadNotification(id, fileName, Progress.createIndeterminateProgress(), 0, 0, |
| 0, isOffTheRecord, canDownloadWhileMetered, true, isTransient, icon); |
| } |
| |
| /** |
| * Helper method to update the notification for an active download, the download is either in |
| * progress or pending. |
| * @param id The {@link ContentId} of the download. |
| * @param fileName File name of the download. |
| * @param progress The current download progress. |
| * @param bytesReceived Total number of bytes received. |
| * @param timeRemainingInMillis Remaining download time in milliseconds or -1 if it is |
| * unknown. |
| * @param startTime Time when download started. |
| * @param isOffTheRecord Whether the download is off the record. |
| * @param canDownloadWhileMetered Whether the download can happen in metered network. |
| * @param isDownloadPending Whether the download is pending. |
| * @param isTransient Whether or not clicking on the download should launch |
| * downloads home. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| */ |
| private void updateActiveDownloadNotification(ContentId id, String fileName, Progress progress, |
| long bytesReceived, long timeRemainingInMillis, long startTime, boolean isOffTheRecord, |
| boolean canDownloadWhileMetered, boolean isDownloadPending, boolean isTransient, |
| Bitmap icon) { |
| boolean indeterminate = (progress.isIndeterminate() || isDownloadPending); |
| String contentText = null; |
| if (isDownloadPending) { |
| contentText = ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_pending); |
| } else if (indeterminate || timeRemainingInMillis < 0) { |
| // TODO(dimich): Enable the byte count back in M59. See bug 704049 for more info and |
| // details of what was temporarily reverted (for M58). |
| contentText = ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_started); |
| } else { |
| contentText = DownloadUtils.getTimeOrFilesLeftString( |
| ContextUtils.getApplicationContext(), progress, timeRemainingInMillis); |
| } |
| int resId = isDownloadPending ? R.drawable.ic_download_pending |
| : android.R.drawable.stat_sys_download; |
| ChromeNotificationBuilder builder = buildNotification(resId, fileName, contentText); |
| builder.setOngoing(true); |
| builder.setPriorityBeforeO(NotificationCompat.PRIORITY_HIGH); |
| |
| // Avoid animations while the download isn't progressing. |
| if (!isDownloadPending) { |
| builder.setProgress(100, indeterminate ? -1 : progress.getPercentage(), indeterminate); |
| } |
| |
| if (!indeterminate && !LegacyHelpers.isLegacyOfflinePage(id)) { |
| String percentText = DownloadUtils.getPercentageString(progress.getPercentage()); |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| builder.setSubText(percentText); |
| } else { |
| builder.setContentInfo(percentText); |
| } |
| } |
| int notificationId = getNotificationId(id); |
| if (startTime > 0) builder.setWhen(startTime); |
| |
| if (!isTransient) { |
| // Clicking on an in-progress download sends the user to see all their downloads. |
| Intent downloadHomeIntent = buildActionIntent(ContextUtils.getApplicationContext(), |
| ACTION_NOTIFICATION_CLICKED, null, isOffTheRecord); |
| builder.setContentIntent( |
| PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId, |
| downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT)); |
| } |
| builder.setAutoCancel(false); |
| if (icon != null) builder.setLargeIcon(icon); |
| |
| Intent pauseIntent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_PAUSE, id, isOffTheRecord); |
| builder.addAction(R.drawable.ic_pause_white_24dp, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_pause_button), |
| buildPendingIntent(pauseIntent, notificationId)); |
| |
| Intent cancelIntent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord); |
| builder.addAction(R.drawable.btn_close_white, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_cancel_button), |
| buildPendingIntent(cancelIntent, notificationId)); |
| |
| updateNotification(notificationId, builder.build(), id, |
| new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord, |
| canDownloadWhileMetered, fileName, true, isTransient)); |
| startTrackingInProgressDownload(id); |
| } |
| |
| /** |
| * Removes a download notification and all associated tracking. This method relies on the |
| * caller to provide the notification id, which is useful in the case where the internal |
| * tracking doesn't exist (e.g. in the case of a successful download, where we show the download |
| * completed notification and remove our internal state tracking). |
| * @param notificationId Notification ID of the download |
| * @param id The {@link ContentId} of the download. |
| */ |
| public void cancelNotification(int notificationId, ContentId id) { |
| mNotificationManager.cancel(NOTIFICATION_NAMESPACE, notificationId); |
| mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id); |
| |
| // Since we are about to go through the process of validating whether or not we can shut |
| // down, don't stop foreground if we have no download notifications left to show. Hiding |
| // the summary will take care of that for us. |
| stopTrackingInProgressDownload(id, hasDownloadNotificationsInternal(notificationId)); |
| if (!hideSummaryNotificationIfNecessary(notificationId)) { |
| updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager, |
| notificationId, null); |
| } |
| } |
| |
| /** |
| * Called when a download is canceled. This method uses internal tracking to try to find the |
| * notification id to cancel. |
| * @param id The {@link ContentId} of the download. |
| */ |
| @VisibleForTesting |
| public void notifyDownloadCanceled(ContentId id) { |
| DownloadSharedPreferenceEntry entry = |
| mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); |
| if (entry == null) { |
| // In case notifyDownloadCanceled was called after the entry has already been removed. |
| stopTrackingInProgressDownload(id, hasDownloadNotificationsInternal(-1)); |
| return; |
| } |
| cancelNotification(entry.notificationId, id); |
| } |
| |
| /** |
| * Change a download notification to paused state. |
| * @param id The {@link ContentId} of the download. |
| * @param fileName File name of the download. |
| * @param isResumable Whether download can be resumed. |
| * @param isAutoResumable Whether download is can be resumed automatically. |
| * @param isOffTheRecord Whether the download is off the record. |
| * @param isTransient Whether or not clicking on the download should launch downloads home. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| */ |
| public void notifyDownloadPaused(ContentId id, String fileName, boolean isResumable, |
| boolean isAutoResumable, boolean isOffTheRecord, boolean isTransient, Bitmap icon) { |
| DownloadSharedPreferenceEntry entry = |
| mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); |
| if (!isResumable) { |
| notifyDownloadFailed(id, fileName, icon); |
| return; |
| } |
| // Download is already paused. |
| if (entry != null && !entry.isAutoResumable) { |
| // Shutdown the service in case it was restarted unnecessarily. |
| stopTrackingInProgressDownload(id, true); |
| return; |
| } |
| boolean canDownloadWhileMetered = entry == null ? false : entry.canDownloadWhileMetered; |
| // If download is interrupted due to network disconnection, show download pending state. |
| if (isAutoResumable) { |
| notifyDownloadPending( |
| id, fileName, isOffTheRecord, canDownloadWhileMetered, isTransient, icon); |
| stopTrackingInProgressDownload(id, true); |
| return; |
| } |
| |
| String contentText = ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_paused); |
| ChromeNotificationBuilder builder = |
| buildNotification(R.drawable.ic_download_pause, fileName, contentText); |
| int notificationId = entry == null ? getNotificationId(id) : entry.notificationId; |
| if (!isTransient) { |
| // Clicking on an in-progress download sends the user to see all their downloads. |
| Intent downloadHomeIntent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_NOTIFICATION_CLICKED, null, false); |
| builder.setContentIntent( |
| PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId, |
| downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT)); |
| } |
| builder.setAutoCancel(false); |
| if (icon != null) builder.setLargeIcon(icon); |
| |
| Intent resumeIntent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_RESUME, id, isOffTheRecord); |
| builder.addAction(R.drawable.ic_file_download_white_24dp, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_resume_button), |
| buildPendingIntent(resumeIntent, notificationId)); |
| |
| Intent cancelIntent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord); |
| builder.addAction(R.drawable.btn_close_white, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_cancel_button), |
| buildPendingIntent(cancelIntent, notificationId)); |
| PendingIntent deleteIntent = isTransient ? buildPendingIntent(cancelIntent, notificationId) |
| : buildSummaryIconIntent(notificationId); |
| builder.setDeleteIntent(deleteIntent); |
| |
| updateNotification(notificationId, builder.build(), id, |
| new DownloadSharedPreferenceEntry(id, notificationId, isOffTheRecord, |
| canDownloadWhileMetered, fileName, isAutoResumable, isTransient)); |
| stopTrackingInProgressDownload(id, true); |
| } |
| |
| /** |
| * Add a download successful notification. |
| * @param id The {@link ContentId} of the download. |
| * @param filePath Full path to the download. |
| * @param fileName Filename of the download. |
| * @param systemDownloadId Download ID assigned by system DownloadManager. |
| * @param isSupportedMimeType Whether the MIME type can be viewed inside browser. |
| * @param isOpenable Whether or not this download can be opened. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| * @param originalUrl The original url of the downloaded file. |
| * @param referrer Referrer of the downloaded file. |
| * @return ID of the successful download notification. Used for removing the |
| * notification when user click on the snackbar. |
| */ |
| @VisibleForTesting |
| public int notifyDownloadSuccessful(ContentId id, String filePath, String fileName, |
| long systemDownloadId, boolean isOffTheRecord, boolean isSupportedMimeType, |
| boolean isOpenable, Bitmap icon, String originalUrl, String referrer) { |
| int notificationId = getNotificationId(id); |
| ChromeNotificationBuilder builder = buildNotification(R.drawable.offline_pin, fileName, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_completed)); |
| ComponentName component = |
| new ComponentName(ContextUtils.getApplicationContext().getPackageName(), |
| DownloadBroadcastReceiver.class.getName()); |
| |
| if (isOpenable) { |
| Intent intent = null; |
| if (LegacyHelpers.isLegacyDownload(id)) { |
| intent = new Intent(ACTION_NOTIFICATION_CLICKED); |
| long[] idArray = {systemDownloadId}; |
| intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, idArray); |
| intent.putExtra(EXTRA_DOWNLOAD_FILE_PATH, filePath); |
| intent.putExtra(EXTRA_IS_SUPPORTED_MIME_TYPE, isSupportedMimeType); |
| intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord); |
| intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id.id); |
| intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id.namespace); |
| intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_ID, notificationId); |
| MediaViewerUtils.setOriginalUrlAndReferralExtraToIntent( |
| intent, originalUrl, referrer); |
| } else { |
| intent = buildActionIntent( |
| ContextUtils.getApplicationContext(), ACTION_DOWNLOAD_OPEN, id, false); |
| } |
| |
| intent.setComponent(component); |
| builder.setContentIntent( |
| PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId, |
| intent, PendingIntent.FLAG_UPDATE_CURRENT)); |
| } |
| if (icon == null && mDownloadSuccessLargeIcon == null) { |
| Bitmap bitmap = BitmapFactory.decodeResource( |
| ContextUtils.getApplicationContext().getResources(), R.drawable.offline_pin); |
| mDownloadSuccessLargeIcon = getLargeNotificationIcon(bitmap); |
| } |
| builder.setDeleteIntent(buildSummaryIconIntent(notificationId)); |
| builder.setLargeIcon(icon != null ? icon : mDownloadSuccessLargeIcon); |
| updateNotification(notificationId, builder.build(), id, null); |
| stopTrackingInProgressDownload(id, true); |
| return notificationId; |
| } |
| |
| /** |
| * Add a download failed notification. |
| * @param id The {@link ContentId} of the download. |
| * @param fileName Filename of the download. |
| * @param icon A {@link Bitmap} to be used as the large icon for display. |
| */ |
| @VisibleForTesting |
| public void notifyDownloadFailed(ContentId id, String fileName, Bitmap icon) { |
| // If the download is not in history db, fileName could be empty. Get it from |
| // SharedPreferences. |
| if (TextUtils.isEmpty(fileName)) { |
| DownloadSharedPreferenceEntry entry = |
| mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); |
| if (entry == null) return; |
| fileName = entry.fileName; |
| } |
| |
| int notificationId = getNotificationId(id); |
| ChromeNotificationBuilder builder = |
| buildNotification(android.R.drawable.stat_sys_download_done, fileName, |
| ContextUtils.getApplicationContext().getResources().getString( |
| R.string.download_notification_failed)); |
| if (icon != null) builder.setLargeIcon(icon); |
| builder.setDeleteIntent(buildSummaryIconIntent(notificationId)); |
| updateNotification(notificationId, builder.build(), id, null); |
| stopTrackingInProgressDownload(id, true); |
| } |
| |
| /** |
| * Helper method to build a PendingIntent from the provided intent. |
| * @param intent Intent to broadcast. |
| * @param notificationId ID of the notification. |
| */ |
| private PendingIntent buildPendingIntent(Intent intent, int notificationId) { |
| return PendingIntent.getBroadcast(ContextUtils.getApplicationContext(), notificationId, |
| intent, PendingIntent.FLAG_UPDATE_CURRENT); |
| } |
| |
| private PendingIntent buildSummaryIconIntent(int notificationId) { |
| Intent intent = |
| new Intent(ContextUtils.getApplicationContext(), DownloadBroadcastReceiver.class); |
| intent.setAction(ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON); |
| return buildPendingIntent(intent, notificationId); |
| } |
| |
| /** |
| * Helper method to build an download action Intent from the provided information. |
| * @param context {@link Context} to pull resources from. |
| * @param action Download action to perform. |
| * @param id The {@link ContentId} of the download. |
| * @param isOffTheRecord Whether the download is incognito. |
| */ |
| static Intent buildActionIntent( |
| Context context, String action, ContentId id, boolean isOffTheRecord) { |
| ComponentName component = new ComponentName( |
| context.getPackageName(), DownloadBroadcastReceiver.class.getName()); |
| Intent intent = new Intent(action); |
| intent.setComponent(component); |
| intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_ID, id != null ? id.id : ""); |
| intent.putExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE, id != null ? id.namespace : ""); |
| intent.putExtra(EXTRA_IS_OFF_THE_RECORD, isOffTheRecord); |
| return intent; |
| } |
| |
| /** |
| * Builds a notification to be displayed. |
| * @param iconId Id of the notification icon. |
| * @param title Title of the notification. |
| * @param contentText Notification content text to be displayed. |
| * @return notification builder that builds the notification to be displayed |
| */ |
| private ChromeNotificationBuilder buildNotification( |
| int iconId, String title, String contentText) { |
| Bundle extras = new Bundle(); |
| extras.putInt(EXTRA_NOTIFICATION_BUNDLE_ICON_ID, iconId); |
| |
| ChromeNotificationBuilder builder = |
| NotificationBuilderFactory |
| .createChromeNotificationBuilder( |
| true /* preferCompat */, ChannelDefinitions.ChannelId.DOWNLOADS) |
| .setContentTitle( |
| DownloadUtils.getAbbreviatedFileName(title, MAX_FILE_NAME_LENGTH)) |
| .setSmallIcon(iconId) |
| .setLocalOnly(true) |
| .setAutoCancel(true) |
| .setContentText(contentText) |
| .setGroup(NotificationConstants.GROUP_DOWNLOADS) |
| .addExtras(extras); |
| return builder; |
| } |
| |
| private Bitmap getLargeNotificationIcon(Bitmap bitmap) { |
| Resources resources = ContextUtils.getApplicationContext().getResources(); |
| int height = (int) resources.getDimension(android.R.dimen.notification_large_icon_height); |
| int width = (int) resources.getDimension(android.R.dimen.notification_large_icon_width); |
| final OvalShape circle = new OvalShape(); |
| circle.resize(width, height); |
| final Paint paint = new Paint(); |
| paint.setColor(ApiCompatibilityUtils.getColor(resources, R.color.google_blue_grey_500)); |
| |
| final Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); |
| Canvas canvas = new Canvas(result); |
| circle.draw(canvas, paint); |
| float leftOffset = (width - bitmap.getWidth()) / 2f; |
| float topOffset = (height - bitmap.getHeight()) / 2f; |
| if (leftOffset >= 0 && topOffset >= 0) { |
| canvas.drawBitmap(bitmap, leftOffset, topOffset, null); |
| } else { |
| // Scale down the icon into the notification icon dimensions |
| canvas.drawBitmap(bitmap, |
| new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()), |
| new Rect(0, 0, width, height), |
| null); |
| } |
| return result; |
| } |
| |
| /** |
| * Retrieves DownloadSharedPreferenceEntry from a download action intent. |
| * @param intent Intent that contains the download action. |
| */ |
| private DownloadSharedPreferenceEntry getDownloadEntryFromIntent(Intent intent) { |
| if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return null; |
| return mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry( |
| getContentIdFromIntent(intent)); |
| } |
| |
| /** |
| * Helper method to launch the browser process and handle a download operation that is included |
| * in the given intent. |
| * @param intent Intent with the download operation. |
| */ |
| private void handleDownloadOperation(final Intent intent) { |
| // Process updating the summary notification first. This has no impact on a specific |
| // download. |
| if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) { |
| updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager, -1, null); |
| hideSummaryNotificationIfNecessary(-1); |
| return; |
| } |
| |
| // TODO(qinmin): Figure out how to properly handle this case. |
| final ContentId id = getContentIdFromIntent(intent); |
| final DownloadSharedPreferenceEntry entry = getDownloadEntryFromIntent(intent); |
| if (entry == null |
| && !(id != null && LegacyHelpers.isLegacyOfflinePage(id) |
| && TextUtils.equals(intent.getAction(), ACTION_DOWNLOAD_OPEN)) |
| && !(TextUtils.equals(intent.getAction(), ACTION_NOTIFICATION_CLICKED)) |
| && !(TextUtils.equals(intent.getAction(), ACTION_DOWNLOAD_RESUME_ALL))) { |
| handleDownloadOperationForMissingNotification(intent); |
| hideSummaryNotificationIfNecessary(-1); |
| return; |
| } |
| |
| if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) { |
| // If browser process already goes away, the download should have already paused. Do |
| // nothing in that case. |
| if (!DownloadManagerService.hasDownloadManagerService()) { |
| // TODO(dtrainor): Should we spin up native to make sure we have the icon? Or maybe |
| // build a Java cache for easy access. |
| notifyDownloadPaused(entry.id, entry.fileName, !entry.isOffTheRecord, false, |
| entry.isOffTheRecord, entry.isTransient, null); |
| hideSummaryNotificationIfNecessary(-1); |
| return; |
| } |
| } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) { |
| // If user manually resumes a download, update the network type if it |
| // is not metered previously. |
| boolean canDownloadWhileMetered = entry.canDownloadWhileMetered |
| || DownloadManagerService.isActiveNetworkMetered( |
| ContextUtils.getApplicationContext()); |
| // Update the SharedPreference entry. |
| mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry( |
| new DownloadSharedPreferenceEntry(entry.id, entry.notificationId, |
| entry.isOffTheRecord, canDownloadWhileMetered, entry.fileName, true, |
| entry.isTransient)); |
| } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction()) |
| && (mDownloadSharedPreferenceHelper.getEntries().isEmpty() |
| || DownloadManagerService.hasDownloadManagerService())) { |
| hideSummaryNotificationIfNecessary(-1); |
| return; |
| } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { |
| // TODO(fgorski): Do we even need to do anything special here, before we launch Chrome? |
| } else if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) |
| && IntentUtils.safeGetBooleanExtra(intent, EXTRA_NOTIFICATION_DISMISSED, false)) { |
| // User canceled a download by dismissing its notification from earlier versions, ignore |
| // it. TODO(qinmin): remove this else-if block after M60. |
| return; |
| } |
| |
| BrowserParts parts = new EmptyBrowserParts() { |
| @Override |
| public void finishNativeInitialization() { |
| // Make sure the OfflineContentAggregator bridge is initialized. |
| OfflineContentAggregatorNotificationBridgeUiFactory.instance(); |
| |
| DownloadServiceDelegate downloadServiceDelegate = |
| isDownloadOpenOrNotificationClickedAction(intent) ? null |
| : getServiceDelegate(id); |
| if (ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())) { |
| // TODO(qinmin): Alternatively, we can delete the downloaded content on |
| // SD card, and remove the download ID from the SharedPreferences so we |
| // don't need to restart the browser process. http://crbug.com/579643. |
| cancelNotification(entry.notificationId, entry.id); |
| downloadServiceDelegate.cancelDownload(entry.id, entry.isOffTheRecord); |
| for (Observer observer : mObservers) { |
| observer.onDownloadCanceled(entry.id); |
| } |
| } else if (ACTION_DOWNLOAD_PAUSE.equals(intent.getAction())) { |
| // TODO(dtrainor): Consider hitting the delegate and rely on that to update the |
| // state. |
| notifyDownloadPaused(entry.id, entry.fileName, true, false, |
| entry.isOffTheRecord, entry.isTransient, null); |
| downloadServiceDelegate.pauseDownload(entry.id, entry.isOffTheRecord); |
| } else if (ACTION_DOWNLOAD_RESUME.equals(intent.getAction())) { |
| // TODO(dtrainor): Consider hitting the delegate and rely on that to update the |
| // state. |
| notifyDownloadPending(entry.id, entry.fileName, entry.isOffTheRecord, |
| entry.canDownloadWhileMetered, entry.isTransient, null); |
| downloadServiceDelegate.resumeDownload( |
| entry.id, entry.buildDownloadItem(), true); |
| } else if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) { |
| assert entry == null; |
| resumeAllPendingDownloads(); |
| } else if (ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) { |
| ContentId id = getContentIdFromIntent(intent); |
| if (id != null) { |
| OfflineContentAggregatorNotificationBridgeUiFactory.instance().openItem(id); |
| } |
| } else if (ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) { |
| openDownload(ContextUtils.getApplicationContext(), intent); |
| } else { |
| Log.e(TAG, "Unrecognized intent action.", intent); |
| } |
| if (!isDownloadOpenOrNotificationClickedAction(intent)) { |
| downloadServiceDelegate.destroyServiceDelegate(); |
| } |
| |
| hideSummaryNotificationIfNecessary(ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) |
| ? entry.notificationId |
| : -1); |
| } |
| }; |
| try { |
| ChromeBrowserInitializer.getInstance(ContextUtils.getApplicationContext()) |
| .handlePreNativeStartup(parts); |
| ChromeBrowserInitializer.getInstance(ContextUtils.getApplicationContext()) |
| .handlePostNativeStartup(true, parts); |
| } catch (ProcessInitException e) { |
| Log.e(TAG, "Unable to load native library.", e); |
| ChromeApplication.reportStartupErrorAndExit(e); |
| } |
| } |
| |
| private boolean isDownloadOpenOrNotificationClickedAction(Intent intent) { |
| return ACTION_DOWNLOAD_OPEN.equals(intent.getAction()) |
| || ACTION_NOTIFICATION_CLICKED.equals(intent.getAction()); |
| } |
| |
| /** |
| * Called to open a particular download item. Falls back to opening Download Home. |
| * @param context Context of the receiver. |
| * @param intent Intent from the android DownloadManager. |
| */ |
| private void openDownload(final Context context, Intent intent) { |
| long ids[] = |
| intent.getLongArrayExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS); |
| if (ids == null || ids.length == 0) { |
| DownloadManagerService.openDownloadsPage(context); |
| return; |
| } |
| |
| long id = ids[0]; |
| Uri uri = DownloadManagerDelegate.getContentUriFromDownloadManager(context, id); |
| if (uri == null) { |
| DownloadManagerService.openDownloadsPage(context); |
| return; |
| } |
| |
| String downloadFilename = IntentUtils.safeGetStringExtra( |
| intent, DownloadNotificationService.EXTRA_DOWNLOAD_FILE_PATH); |
| boolean isSupportedMimeType = IntentUtils.safeGetBooleanExtra( |
| intent, DownloadNotificationService.EXTRA_IS_SUPPORTED_MIME_TYPE, false); |
| boolean isOffTheRecord = IntentUtils.safeGetBooleanExtra( |
| intent, DownloadNotificationService.EXTRA_IS_OFF_THE_RECORD, false); |
| String originalUrl = IntentUtils.safeGetStringExtra(intent, Intent.EXTRA_ORIGINATING_URI); |
| String referrer = IntentUtils.safeGetStringExtra(intent, Intent.EXTRA_REFERRER); |
| ContentId contentId = DownloadNotificationService.getContentIdFromIntent(intent); |
| DownloadManagerService.openDownloadedContent(context, downloadFilename, isSupportedMimeType, |
| isOffTheRecord, contentId.id, id, originalUrl, referrer, |
| DownloadMetrics.DownloadOpenSource.NOTIFICATION); |
| } |
| |
| /** |
| * Handles operations for downloads that the DownloadNotificationService is unaware of. |
| * |
| * This can happen because the DownloadNotificationService learn about downloads later than |
| * Download Home does, and may not yet have a DownloadSharedPreferenceEntry for the item. |
| * |
| * TODO(qinmin): Figure out how to fix the SharedPreferences so that it properly tracks entries. |
| */ |
| private void handleDownloadOperationForMissingNotification(Intent intent) { |
| // This function should only be called via Download Home, but catch this case to be safe. |
| if (!DownloadManagerService.hasDownloadManagerService()) return; |
| |
| String action = intent.getAction(); |
| ContentId id = getContentIdFromIntent(intent); |
| boolean isOffTheRecord = |
| IntentUtils.safeGetBooleanExtra(intent, EXTRA_IS_OFF_THE_RECORD, false); |
| if (!LegacyHelpers.isLegacyDownload(id)) return; |
| |
| // Pass information directly to the DownloadManagerService. |
| if (TextUtils.equals(action, ACTION_DOWNLOAD_CANCEL)) { |
| getServiceDelegate(id).cancelDownload(id, isOffTheRecord); |
| } else if (TextUtils.equals(action, ACTION_DOWNLOAD_PAUSE)) { |
| getServiceDelegate(id).pauseDownload(id, isOffTheRecord); |
| } else if (TextUtils.equals(action, ACTION_DOWNLOAD_RESUME)) { |
| DownloadInfo info = new DownloadInfo.Builder() |
| .setDownloadGuid(id.id) |
| .setIsOffTheRecord(isOffTheRecord) |
| .build(); |
| getServiceDelegate(id).resumeDownload(id, new DownloadItem(false, info), true); |
| } |
| } |
| |
| /** |
| * Gets appropriate download delegate that can handle interactions with download item referred |
| * to by the entry. |
| * @param id The {@link ContentId} to grab the delegate for. |
| * @return delegate for interactions with the entry |
| */ |
| DownloadServiceDelegate getServiceDelegate(ContentId id) { |
| return LegacyHelpers.isLegacyDownload(id) |
| ? DownloadManagerService.getDownloadManagerService() |
| : OfflineContentAggregatorNotificationBridgeUiFactory.instance(); |
| } |
| |
| @VisibleForTesting |
| void updateNotification(int id, Notification notification) { |
| // Disabling StrictMode to avoid violations (crbug.com/809864). |
| try (StrictModeContext unused = StrictModeContext.allowDiskReads()) { |
| mNotificationManager.notify(NOTIFICATION_NAMESPACE, id, notification); |
| } |
| } |
| |
| private void updateNotification(int notificationId, Notification notification, ContentId id, |
| DownloadSharedPreferenceEntry entry) { |
| updateNotification(notificationId, notification); |
| trackNotificationUma(id); |
| |
| if (entry != null) { |
| mDownloadSharedPreferenceHelper.addOrReplaceSharedPreferenceEntry(entry); |
| } else { |
| mDownloadSharedPreferenceHelper.removeSharedPreferenceEntry(id); |
| } |
| updateSummaryIcon(ContextUtils.getApplicationContext(), mNotificationManager, -1, |
| new Pair<Integer, Notification>(notificationId, notification)); |
| } |
| |
| private void trackNotificationUma(ContentId id) { |
| // Check if we already have an entry in the DownloadSharedPreferenceHelper. This is a |
| // reasonable indicator for whether or not a notification is already showing (or at least if |
| // we had built one for this download before. |
| if (mDownloadSharedPreferenceHelper.hasEntry(id)) return; |
| NotificationUmaTracker.getInstance().onNotificationShown( |
| LegacyHelpers.isLegacyOfflinePage(id) |
| ? NotificationUmaTracker.SystemNotificationType.DOWNLOAD_PAGES |
| : NotificationUmaTracker.SystemNotificationType.DOWNLOAD_FILES, |
| ChannelDefinitions.ChannelId.DOWNLOADS); |
| |
| // Record number of other notifications when there's a new notification. |
| DownloadNotificationUmaHelper.recordExistingNotificationsCountHistogram( |
| mDownloadSharedPreferenceHelper.getEntries().size(), false /* withForeground */); |
| } |
| |
| /** |
| * Checks if an intent requires operations on a download. |
| * @param intent An intent to validate. |
| * @return true if the intent requires actions, or false otherwise. |
| */ |
| static boolean isDownloadOperationIntent(Intent intent) { |
| if (intent == null) return false; |
| if (ACTION_DOWNLOAD_UPDATE_SUMMARY_ICON.equals(intent.getAction())) return true; |
| if (ACTION_DOWNLOAD_RESUME_ALL.equals(intent.getAction())) return true; |
| if (!ACTION_DOWNLOAD_CANCEL.equals(intent.getAction()) |
| && !ACTION_DOWNLOAD_RESUME.equals(intent.getAction()) |
| && !ACTION_DOWNLOAD_PAUSE.equals(intent.getAction()) |
| && !ACTION_DOWNLOAD_OPEN.equals(intent.getAction()) |
| && !ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) { |
| return false; |
| } |
| |
| ContentId id = getContentIdFromIntent(intent); |
| if (id == null) return false; |
| return true; |
| } |
| |
| private static boolean canResumeDownload(Context context, DownloadSharedPreferenceEntry entry) { |
| if (entry == null) return false; |
| if (!entry.isAutoResumable) return false; |
| |
| boolean isNetworkMetered = DownloadManagerService.isActiveNetworkMetered(context); |
| return entry.canDownloadWhileMetered || !isNetworkMetered; |
| } |
| |
| /** |
| * @param intent The {@link Intent} to pull from and build a {@link ContentId}. |
| * @return A {@link ContentId} built by pulling extras from {@code intent}. This will be |
| * {@code null} if {@code intent} is missing any required extras. |
| */ |
| public static ContentId getContentIdFromIntent(Intent intent) { |
| if (!intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_ID) |
| || !intent.hasExtra(EXTRA_DOWNLOAD_CONTENTID_NAMESPACE)) { |
| return null; |
| } |
| |
| return new ContentId( |
| IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_NAMESPACE), |
| IntentUtils.safeGetStringExtra(intent, EXTRA_DOWNLOAD_CONTENTID_ID)); |
| } |
| |
| /** |
| * Resumes all pending downloads from SharedPreferences. If a download is |
| * already in progress, do nothing. |
| */ |
| public void resumeAllPendingDownloads() { |
| if (!DownloadManagerService.hasDownloadManagerService()) return; |
| List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries(); |
| for (int i = 0; i < entries.size(); ++i) { |
| DownloadSharedPreferenceEntry entry = entries.get(i); |
| if (!canResumeDownload(ContextUtils.getApplicationContext(), entry)) continue; |
| if (mDownloadsInProgress.contains(entry.id)) continue; |
| |
| notifyDownloadPending(entry.id, entry.fileName, entry.isOffTheRecord, |
| entry.canDownloadWhileMetered, entry.isTransient, null); |
| DownloadServiceDelegate downloadServiceDelegate = getServiceDelegate(entry.id); |
| downloadServiceDelegate.resumeDownload(entry.id, entry.buildDownloadItem(), false); |
| downloadServiceDelegate.destroyServiceDelegate(); |
| } |
| } |
| |
| /** |
| * Return the notification ID for the given download {@link ContentId}. |
| * @param id the {@link ContentId} of the download. |
| * @return notification ID to be used. |
| */ |
| private int getNotificationId(ContentId id) { |
| DownloadSharedPreferenceEntry entry = |
| mDownloadSharedPreferenceHelper.getDownloadSharedPreferenceEntry(id); |
| if (entry != null) return entry.notificationId; |
| int notificationId = mNextNotificationId; |
| mNextNotificationId = mNextNotificationId == Integer.MAX_VALUE |
| ? STARTING_NOTIFICATION_ID : mNextNotificationId + 1; |
| SharedPreferences.Editor editor = mSharedPrefs.edit(); |
| editor.putInt(KEY_NEXT_DOWNLOAD_NOTIFICATION_ID, mNextNotificationId); |
| editor.apply(); |
| return notificationId; |
| } |
| } |