// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.download;

import android.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.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Pair;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.library_loader.LibraryProcessType;
import org.chromium.base.library_loader.ProcessInitException;
import org.chromium.base.metrics.RecordHistogram;
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.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.offlinepages.downloads.OfflinePageDownloadBridge;
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.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 Context mContext;
    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 BuildInfo.isAtLeastO();
    }

    /**
     * Checks to see if the summary notification is alone and, if so, hides it.  If the summary
     * notification thinks it's in the foreground, this will start the service with the goal of
     * shutting it down.  That is because if the service is in the foreground it's not possible to
     * stop it through the notification manager.
     * @param removedNotificationId The id of the notification that was just removed or {@code -1}
     *                              if this does not apply.
     */
    @TargetApi(Build.VERSION_CODES.M)
    public static void hideDanglingSummaryNotification(Context context, int removedNotificationId) {
        if (!useForegroundService()) return;

        NotificationManager manager =
                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
        if (hasDownloadNotifications(manager, removedNotificationId)) return;

        StatusBarNotification summary = getSummaryNotification(manager);
        if (summary == null) return;

        boolean isForeground =
                (summary.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;

        if (BrowserStartupController.get(LibraryProcessType.PROCESS_BROWSER)
                        .isStartupSuccessfullyCompleted()) {
            RecordHistogram.recordBooleanHistogram(
                    "MobileDownload.Notification.FixingSummaryLeak", isForeground);
        }

        if (isForeground) {
            // If it is a foreground notification, we are in a bad state.  We don't have any
            // other download notifications, but we can't close the summary.  Try to start
            // up the service and quit through that path?
            startDownloadNotificationService(context, new Intent(ACTION_DOWNLOAD_FAIL_SAFE));
        } else {
            manager.cancel(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY);
        }
    }

    /**
     * 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.CHANNEL_ID_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, DownloadManager.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) {
        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 onCreate() {
        mContext = ContextUtils.getApplicationContext();
        mNotificationManager = (NotificationManager) mContext.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() {
        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) {
            // 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(mContext).cancelTask();
            // 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) {
        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() {
        if (!useForegroundService()) return;
        Notification notification =
                buildSummaryNotification(getApplicationContext(), mNotificationManager);
        startForeground(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_SUMMARY, notification);
    }

    @VisibleForTesting
    boolean hasDownloadNotificationsInternal(int notificationIdToIgnore) {
        return hasDownloadNotifications(mNotificationManager, notificationIdToIgnore);
    }

    @VisibleForTesting
    void updateSummaryIconInternal(
            int removedNotificationId, Pair<Integer, Notification> addedNotification) {
        updateSummaryIcon(mContext, mNotificationManager, removedNotificationId, addedNotification);
    }

    private void rescheduleDownloads() {
        List<DownloadSharedPreferenceEntry> entries = mDownloadSharedPreferenceHelper.getEntries();
        if (entries.isEmpty()) return;

        boolean scheduleAutoResumption = false;
        boolean allowMeteredConnection = false;
        for (int i = 0; i < entries.size(); ++i) {
            DownloadSharedPreferenceEntry entry = entries.get(i);
            if (entry.isAutoResumable) {
                scheduleAutoResumption = true;
                if (entry.canDownloadWhileMetered) {
                    allowMeteredConnection = true;
                    break;
                }
            }
        }
        if (scheduleAutoResumption && mNumAutoResumptionAttemptLeft > 0) {
            DownloadResumptionScheduler.getDownloadResumptionScheduler(mContext).schedule(
                    allowMeteredConnection);
        }
    }

    @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) {
        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) {
        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.
     */
    @TargetApi(Build.VERSION_CODES.M)
    boolean hideSummaryNotificationIfNecessary(int notificationIdToIgnore) {
        if (!useForegroundService()) return false;
        if (mDownloadsInProgress.size() > 0) return false;

        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);
        }

        // 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();
        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 = mContext.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 = mContext.getResources().getString(R.string.download_started);
        } else {
            contentText = DownloadUtils.getTimeOrFilesLeftString(
                    mContext, 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.setPriority(Notification.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(
                    mContext, DownloadManager.ACTION_NOTIFICATION_CLICKED, null, isOffTheRecord);
            builder.setContentIntent(PendingIntent.getBroadcast(mContext, notificationId,
                    downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        builder.setAutoCancel(false);
        if (icon != null) builder.setLargeIcon(icon);

        Intent pauseIntent = buildActionIntent(mContext, ACTION_DOWNLOAD_PAUSE, id, isOffTheRecord);
        builder.addAction(R.drawable.ic_pause_white_24dp,
                mContext.getResources().getString(R.string.download_notification_pause_button),
                buildPendingIntent(pauseIntent, notificationId));

        Intent cancelIntent =
                buildActionIntent(mContext, ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord);
        builder.addAction(R.drawable.btn_close_white,
                mContext.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(mContext, 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) 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;
        }
        // If download is already paused, do nothing.
        if (entry != null && !entry.isAutoResumable) 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 = mContext.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(
                    mContext, DownloadManager.ACTION_NOTIFICATION_CLICKED, null, false);
            builder.setContentIntent(PendingIntent.getBroadcast(mContext, notificationId,
                    downloadHomeIntent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        builder.setAutoCancel(false);
        if (icon != null) builder.setLargeIcon(icon);

        Intent resumeIntent =
                buildActionIntent(mContext, ACTION_DOWNLOAD_RESUME, id, isOffTheRecord);
        builder.addAction(R.drawable.ic_file_download_white_24dp,
                mContext.getResources().getString(R.string.download_notification_resume_button),
                buildPendingIntent(resumeIntent, notificationId));

        Intent cancelIntent =
                buildActionIntent(mContext, ACTION_DOWNLOAD_CANCEL, id, isOffTheRecord);
        builder.addAction(R.drawable.btn_close_white,
                mContext.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.
     * @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) {
        int notificationId = getNotificationId(id);
        ChromeNotificationBuilder builder = buildNotification(R.drawable.offline_pin, fileName,
                mContext.getResources().getString(R.string.download_notification_completed));
        ComponentName component = new ComponentName(
                mContext.getPackageName(), DownloadBroadcastReceiver.class.getName());

        if (isOpenable) {
            Intent intent = null;
            if (LegacyHelpers.isLegacyDownload(id)) {
                intent = new Intent(DownloadManager.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);
            } else {
                intent = buildActionIntent(mContext, ACTION_DOWNLOAD_OPEN, id, false);
            }

            intent.setComponent(component);
            builder.setContentIntent(PendingIntent.getBroadcast(
                    mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT));
        }
        if (icon == null && mDownloadSuccessLargeIcon == null) {
            Bitmap bitmap = BitmapFactory.decodeResource(
                    mContext.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,
                        mContext.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(
                mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    }

    private PendingIntent buildSummaryIconIntent(int notificationId) {
        Intent intent = new Intent(mContext, 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.CHANNEL_ID_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 = mContext.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(mContext, 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))) {
            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(mContext);
            // 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 =
                        ACTION_DOWNLOAD_OPEN.equals(intent.getAction()) ? 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 (LegacyHelpers.isLegacyOfflinePage(id)) {
                        OfflinePageDownloadBridge.openDownloadedPage(id);
                    } else if (id != null) {
                        OfflineContentAggregatorNotificationBridgeUiFactory.instance().openItem(id);
                    }
                } else {
                        Log.e(TAG, "Unrecognized intent action.", intent);
                }
                if (!ACTION_DOWNLOAD_OPEN.equals(intent.getAction())) {
                    downloadServiceDelegate.destroyServiceDelegate();
                }

                hideSummaryNotificationIfNecessary(ACTION_DOWNLOAD_CANCEL.equals(intent.getAction())
                                ? entry.notificationId
                                : -1);
            }
        };
        try {
            ChromeBrowserInitializer.getInstance(mContext).handlePreNativeStartup(parts);
            ChromeBrowserInitializer.getInstance(mContext).handlePostNativeStartup(true, parts);
        } catch (ProcessInitException e) {
            Log.e(TAG, "Unable to load native library.", e);
            ChromeApplication.reportStartupErrorAndExit(e);
        }
    }

    /**
     * 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) {
        if (LegacyHelpers.isLegacyOfflinePage(id)) {
            return OfflinePageDownloadBridge.getDownloadServiceDelegate();
        }
        if (LegacyHelpers.isLegacyDownload(id)) {
            return DownloadManagerService.getDownloadManagerService();
        }
        return OfflineContentAggregatorNotificationBridgeUiFactory.instance();
    }

    @VisibleForTesting
    void updateNotification(int id, Notification notification) {
        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(mContext, 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.DOWNLOAD_PAGES
                                                      : NotificationUmaTracker.DOWNLOAD_FILES,
                ChannelDefinitions.CHANNEL_ID_DOWNLOADS);
    }

    /**
     * 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())) {
            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(mContext, entry)) continue;
            if (mDownloadsInProgress.contains(entry.id)) continue;

            notifyDownloadPending(entry.id, entry.fileName, false, 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;
    }
}