blob: 47d3bfc40857cd3609809e91a83b19961f25be74 [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.download;
import 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;
}
}