Rewrite of download notifications.

Switch to using new inbox-style notifications when collapsing multiple
downloads. Correctly handles clustering, including cancellation of
stale notifications. All notifications are now handled in a single
class, making it easier to reason about correctness.

Fixed bugs around handling of visibility flags. Move away from using
"int" as internal keys, since they can overflow. Started work for
time estimates, will finish in a future CL.

Explicitly pass all relevant IDs to DownloadReceiver instead of doing
a second racy query. Fix StrictMode warnings when querying in
DownloadReceiver.

Bug: 6777872, 5463678, 6663547, 6967346, 6634261, 5608365
Change-Id: I5eb47b73b90b6250acec2ce5bf8d7a274ed9d3a9
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 5635295..c607e35 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -116,23 +116,6 @@
     for a download that doesn't have a title associated with it. -->
     <string name="download_unknown_title">&lt;Untitled&gt;</string>
 
-    <!-- When there are multiple simultaneous outstanding downloads from a
-        single application, they are displayed as a single notification,
-        and the expanded notification view displays the first two download
-        names separated with this string, i.e. "[title], [title]"
-        or "[title], [title] and [n] more". This is the comma + space
-        that separates the first two titles, and it's used both when there
-        are exactly two and more than two titles. -->
-    <string name="notification_filename_separator">", "</string>
-
-    <!-- When there are three or more simultaneous outstanding downloads from a
-        single application, they are displayed as a single notification,
-        and the expanded notification view uses this string to indicate
-        downloads beyond the first two, i.e. "[title], [title] and [n] more".
-        This is the " and [n] more" part, including the leading space, and it's
-        used regardless of the number of additional downloads. -->
-    <string name="notification_filename_extras">" and <xliff:g id="number" example="27">%d</xliff:g> more"</string>
-
     <!-- When a download completes, a notification is displayed, and this
         string is used to indicate that the download successfully completed.
         Note that such a download could have been initiated by a variety of
@@ -210,4 +193,21 @@
     <!-- Short representation of download progress percentage. [CHAR LIMIT=8] -->
     <string name="download_percent"><xliff:g id="number">%d</xliff:g><xliff:g id="percent">%%</xliff:g></string>
 
+    <!-- Title summarizing active downloads. [CHAR LIMIT=32] -->
+    <plurals name="notif_summary_active">
+        <item quantity="one">1 file downloading</item>
+        <item quantity="other"><xliff:g id="number">%d</xliff:g> files downloading</item>
+    </plurals>
+
+    <!-- Title summarizing waiting downloads. [CHAR LIMIT=32] -->
+    <plurals name="notif_summary_waiting">
+        <item quantity="one">1 file waiting</item>
+        <item quantity="other"><xliff:g id="number">%d</xliff:g> files waiting</item>
+    </plurals>
+
+    <!-- Text for a toast appearing when a user clicks on a completed download, informing the user
+         that there is no application on the device that can open the file that was downloaded
+         [CHAR LIMIT=200] -->
+    <string name="download_no_application_title">Can\'t open file</string>
+
 </resources>
diff --git a/src/com/android/providers/downloads/DownloadNotification.java b/src/com/android/providers/downloads/DownloadNotification.java
deleted file mode 100644
index f5778e7..0000000
--- a/src/com/android/providers/downloads/DownloadNotification.java
+++ /dev/null
@@ -1,288 +0,0 @@
-/*
- * Copyright (C) 2008 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.providers.downloads;
-
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.ContentUris;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.provider.Downloads;
-import android.text.TextUtils;
-import android.util.SparseLongArray;
-
-import java.util.Collection;
-import java.util.HashMap;
-
-/**
- * This class handles the updating of the Notification Manager for the
- * cases where there is an ongoing download. Once the download is complete
- * (be it successful or unsuccessful) it is no longer the responsibility
- * of this component to show the download in the notification manager.
- *
- */
-class DownloadNotification {
-
-    private Context mContext;
-    private NotificationManager mNotifManager;
-    private HashMap<String, NotificationItem> mNotifications;
-
-    /** Time when each {@link DownloadInfo#mId} was first shown. */
-    private SparseLongArray mFirstShown = new SparseLongArray();
-
-    static final String LOGTAG = "DownloadNotification";
-    static final String WHERE_RUNNING =
-        "(" + Downloads.Impl.COLUMN_STATUS + " >= '100') AND (" +
-        Downloads.Impl.COLUMN_STATUS + " <= '199') AND (" +
-        Downloads.Impl.COLUMN_VISIBILITY + " IS NULL OR " +
-        Downloads.Impl.COLUMN_VISIBILITY + " == '" + Downloads.Impl.VISIBILITY_VISIBLE + "' OR " +
-        Downloads.Impl.COLUMN_VISIBILITY +
-            " == '" + Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + "')";
-    static final String WHERE_COMPLETED =
-        Downloads.Impl.COLUMN_STATUS + " >= '200' AND " +
-        Downloads.Impl.COLUMN_VISIBILITY +
-            " == '" + Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + "'";
-
-
-    /**
-     * This inner class is used to collate downloads that are owned by
-     * the same application. This is so that only one notification line
-     * item is used for all downloads of a given application.
-     *
-     */
-    static class NotificationItem {
-        // TODO: refactor to mNotifId and avoid building Uris based on it, since
-        // they can overflow
-        int mId;  // This first db _id for the download for the app
-        long mTotalCurrent = 0;
-        long mTotalTotal = 0;
-        int mTitleCount = 0;
-        String mPackageName;  // App package name
-        String mDescription;
-        String[] mTitles = new String[2]; // download titles.
-        String mPausedText = null;
-
-        /*
-         * Add a second download to this notification item.
-         */
-        void addItem(String title, long currentBytes, long totalBytes) {
-            mTotalCurrent += currentBytes;
-            if (totalBytes <= 0 || mTotalTotal == -1) {
-                mTotalTotal = -1;
-            } else {
-                mTotalTotal += totalBytes;
-            }
-            if (mTitleCount < 2) {
-                mTitles[mTitleCount] = title;
-            }
-            mTitleCount++;
-        }
-    }
-
-
-    /**
-     * Constructor
-     * @param ctx The context to use to obtain access to the
-     *            Notification Service
-     */
-    DownloadNotification(Context ctx, SystemFacade systemFacade) {
-        mContext = ctx;
-        mNotifManager = (NotificationManager) mContext.getSystemService(
-                Context.NOTIFICATION_SERVICE);
-        mNotifications = new HashMap<String, NotificationItem>();
-    }
-
-    /*
-     * Update the notification ui.
-     */
-    public void updateNotification(Collection<DownloadInfo> downloads) {
-        updateActiveNotification(downloads);
-        updateCompletedNotification(downloads);
-    }
-
-    private void updateActiveNotification(Collection<DownloadInfo> downloads) {
-        // Collate the notifications
-        mNotifications.clear();
-        for (DownloadInfo download : downloads) {
-            if (!isActiveAndVisible(download)) {
-                continue;
-            }
-            String packageName = download.mPackage;
-            long max = download.mTotalBytes;
-            long progress = download.mCurrentBytes;
-            long id = download.mId;
-            String title = download.mTitle;
-            if (title == null || title.length() == 0) {
-                title = mContext.getResources().getString(
-                        R.string.download_unknown_title);
-            }
-
-            NotificationItem item;
-            if (mNotifications.containsKey(packageName)) {
-                item = mNotifications.get(packageName);
-                item.addItem(title, progress, max);
-            } else {
-                item = new NotificationItem();
-                item.mId = (int) id;
-                item.mPackageName = packageName;
-                item.mDescription = download.mDescription;
-                item.addItem(title, progress, max);
-                mNotifications.put(packageName, item);
-            }
-            if (download.mStatus == Downloads.Impl.STATUS_QUEUED_FOR_WIFI
-                    && item.mPausedText == null) {
-                item.mPausedText = mContext.getResources().getString(
-                        R.string.notification_need_wifi_for_size);
-            }
-        }
-
-        // Add the notifications
-        for (NotificationItem item : mNotifications.values()) {
-            // Build the notification object
-            final Notification.Builder builder = new Notification.Builder(mContext);
-
-            boolean hasPausedText = (item.mPausedText != null);
-            int iconResource = android.R.drawable.stat_sys_download;
-            if (hasPausedText) {
-                iconResource = android.R.drawable.stat_sys_warning;
-            }
-            builder.setSmallIcon(iconResource);
-            builder.setOngoing(true);
-
-            // set notification "when" to be first time this DownloadInfo.mId
-            // was encountered, which avoids fighting with other notifs.
-            long firstShown = mFirstShown.get(item.mId, -1);
-            if (firstShown == -1) {
-                firstShown = System.currentTimeMillis();
-                mFirstShown.put(item.mId, firstShown);
-            }
-            builder.setWhen(firstShown);
-
-            boolean hasContentText = false;
-            StringBuilder title = new StringBuilder(item.mTitles[0]);
-            if (item.mTitleCount > 1) {
-                title.append(mContext.getString(R.string.notification_filename_separator));
-                title.append(item.mTitles[1]);
-                if (item.mTitleCount > 2) {
-                    title.append(mContext.getString(R.string.notification_filename_extras,
-                            new Object[] { Integer.valueOf(item.mTitleCount - 2) }));
-                }
-            } else if (!TextUtils.isEmpty(item.mDescription)) {
-                builder.setContentText(item.mDescription);
-                hasContentText = true;
-            }
-            builder.setContentTitle(title);
-
-            if (hasPausedText) {
-                builder.setContentText(item.mPausedText);
-            } else {
-                builder.setProgress(
-                        (int) item.mTotalTotal, (int) item.mTotalCurrent, item.mTotalTotal == -1);
-                if (hasContentText) {
-                    builder.setContentInfo(
-                            buildPercentageLabel(mContext, item.mTotalTotal, item.mTotalCurrent));
-                }
-            }
-
-            Intent intent = new Intent(Constants.ACTION_LIST);
-            intent.setClassName("com.android.providers.downloads",
-                    DownloadReceiver.class.getName());
-            intent.setData(
-                    ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, item.mId));
-            intent.putExtra("multiple", item.mTitleCount > 1);
-
-            builder.setContentIntent(PendingIntent.getBroadcast(mContext, 0, intent, 0));
-
-            mNotifManager.notify(item.mId, builder.build());
-        }
-    }
-
-    private void updateCompletedNotification(Collection<DownloadInfo> downloads) {
-        for (DownloadInfo download : downloads) {
-            if (!isCompleteAndVisible(download)) {
-                continue;
-            }
-            notificationForCompletedDownload(download.mId, download.mTitle,
-                    download.mStatus, download.mDestination, download.mLastMod);
-        }
-    }
-    void notificationForCompletedDownload(long id, String title, int status,
-            int destination, long lastMod) {
-        // Add the notifications
-        Notification.Builder builder = new Notification.Builder(mContext);
-        builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
-        if (title == null || title.length() == 0) {
-            title = mContext.getResources().getString(
-                    R.string.download_unknown_title);
-        }
-        Uri contentUri =
-            ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
-        String caption;
-        Intent intent;
-        if (Downloads.Impl.isStatusError(status)) {
-            caption = mContext.getResources()
-                    .getString(R.string.notification_download_failed);
-            intent = new Intent(Constants.ACTION_LIST);
-        } else {
-            caption = mContext.getResources()
-                    .getString(R.string.notification_download_complete);
-            if (destination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) {
-                intent = new Intent(Constants.ACTION_OPEN);
-            } else {
-                intent = new Intent(Constants.ACTION_LIST);
-            }
-        }
-        intent.setClassName("com.android.providers.downloads",
-                DownloadReceiver.class.getName());
-        intent.setData(contentUri);
-
-        builder.setWhen(lastMod);
-        builder.setContentTitle(title);
-        builder.setContentText(caption);
-        builder.setContentIntent(PendingIntent.getBroadcast(mContext, 0, intent, 0));
-
-        intent = new Intent(Constants.ACTION_HIDE);
-        intent.setClassName("com.android.providers.downloads",
-                DownloadReceiver.class.getName());
-        intent.setData(contentUri);
-        builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, intent, 0));
-
-        mNotifManager.notify((int) id, builder.build());
-    }
-
-    private boolean isActiveAndVisible(DownloadInfo download) {
-        return 100 <= download.mStatus && download.mStatus < 200
-                && download.mVisibility != Downloads.Impl.VISIBILITY_HIDDEN;
-    }
-
-    private boolean isCompleteAndVisible(DownloadInfo download) {
-        return download.mStatus >= 200
-                && download.mVisibility == Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
-    }
-
-    private static String buildPercentageLabel(
-            Context context, long totalBytes, long currentBytes) {
-        if (totalBytes <= 0) {
-            return null;
-        } else {
-            final int percent = (int) (100 * currentBytes / totalBytes);
-            return context.getString(R.string.download_percent, percent);
-        }
-    }
-}
diff --git a/src/com/android/providers/downloads/DownloadNotifier.java b/src/com/android/providers/downloads/DownloadNotifier.java
new file mode 100644
index 0000000..a1805e5
--- /dev/null
+++ b/src/com/android/providers/downloads/DownloadNotifier.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.providers.downloads;
+
+import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE;
+import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
+import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
+
+import android.app.DownloadManager;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentUris;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.provider.Downloads;
+import android.text.TextUtils;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Sets;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Set;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Update {@link NotificationManager} to reflect current {@link DownloadInfo}
+ * states. Collapses similar downloads into a single notification, and builds
+ * {@link PendingIntent} that launch towards {@link DownloadReceiver}.
+ */
+public class DownloadNotifier {
+
+    private static final int TYPE_ACTIVE = 1;
+    private static final int TYPE_WAITING = 2;
+    private static final int TYPE_COMPLETE = 3;
+
+    private final Context mContext;
+    private final NotificationManager mNotifManager;
+
+    /**
+     * Currently active notifications, mapped from clustering tag to timestamp
+     * when first shown.
+     *
+     * @see #buildNotificationTag(DownloadInfo)
+     */
+    @GuardedBy("mActiveNotifs")
+    private final HashMap<String, Long> mActiveNotifs = Maps.newHashMap();
+
+    public DownloadNotifier(Context context) {
+        mContext = context;
+        mNotifManager = (NotificationManager) context.getSystemService(
+                Context.NOTIFICATION_SERVICE);
+    }
+
+    /**
+     * Update {@link NotificationManager} to reflect the given set of
+     * {@link DownloadInfo}, adding, collapsing, and removing as needed.
+     */
+    public void updateWith(Collection<DownloadInfo> downloads) {
+        synchronized (mActiveNotifs) {
+            updateWithLocked(downloads);
+        }
+    }
+
+    private void updateWithLocked(Collection<DownloadInfo> downloads) {
+        final Resources res = mContext.getResources();
+
+        // Cluster downloads together
+        final Multimap<String, DownloadInfo> clustered = ArrayListMultimap.create();
+        for (DownloadInfo info : downloads) {
+            final String tag = buildNotificationTag(info);
+            if (tag != null) {
+                clustered.put(tag, info);
+            }
+        }
+
+        // Build notification for each cluster
+        for (String tag : clustered.keySet()) {
+            final int type = getNotificationTagType(tag);
+            final Collection<DownloadInfo> cluster = clustered.get(tag);
+
+            final Notification.Builder builder = new Notification.Builder(mContext);
+
+            // Use time when cluster was first shown to avoid shuffling
+            final long firstShown;
+            if (mActiveNotifs.containsKey(tag)) {
+                firstShown = mActiveNotifs.get(tag);
+            } else {
+                firstShown = System.currentTimeMillis();
+                mActiveNotifs.put(tag, firstShown);
+            }
+            builder.setWhen(firstShown);
+
+            // Show relevant icon
+            if (type == TYPE_ACTIVE) {
+                builder.setSmallIcon(android.R.drawable.stat_sys_download);
+            } else if (type == TYPE_WAITING) {
+                builder.setSmallIcon(android.R.drawable.stat_sys_warning);
+            } else if (type == TYPE_COMPLETE) {
+                builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
+            }
+
+            // Build action intents
+            if (type == TYPE_ACTIVE || type == TYPE_WAITING) {
+                final Intent intent = new Intent(Constants.ACTION_LIST,
+                        null, mContext, DownloadReceiver.class);
+                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
+                        getDownloadIds(cluster));
+                builder.setContentIntent(PendingIntent.getBroadcast(mContext, 0, intent, 0));
+                builder.setOngoing(true);
+
+            } else if (type == TYPE_COMPLETE) {
+                final DownloadInfo info = cluster.iterator().next();
+                final Uri uri = ContentUris.withAppendedId(
+                        Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, info.mId);
+
+                final String action;
+                if (Downloads.Impl.isStatusError(info.mStatus)) {
+                    action = Constants.ACTION_LIST;
+                } else {
+                    if (info.mDestination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) {
+                        action = Constants.ACTION_OPEN;
+                    } else {
+                        action = Constants.ACTION_LIST;
+                    }
+                }
+
+                final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
+                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
+                        getDownloadIds(cluster));
+                builder.setContentIntent(PendingIntent.getBroadcast(mContext, 0, intent, 0));
+
+                final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
+                        uri, mContext, DownloadReceiver.class);
+                builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent, 0));
+            }
+
+            // Calculate and show progress
+            String remainingText = null;
+            String percentText = null;
+            if (type == TYPE_ACTIVE) {
+                long current = 0;
+                long total = 0;
+                for (DownloadInfo info : cluster) {
+                    if (info.mTotalBytes != -1) {
+                        current += info.mCurrentBytes;
+                        total += info.mTotalBytes;
+                    }
+                }
+
+                if (total > 0) {
+                    final int percent = (int) ((current * 100) / total);
+                    // TODO: calculate remaining time based on recent bandwidth
+                    percentText = res.getString(R.string.download_percent, percent);
+
+                    builder.setProgress(100, percent, false);
+                } else {
+                    builder.setProgress(100, 0, true);
+                }
+            }
+
+            // Build titles and description
+            final Notification notif;
+            if (cluster.size() == 1) {
+                final DownloadInfo info = cluster.iterator().next();
+
+                builder.setContentTitle(getDownloadTitle(res, info));
+
+                if (type == TYPE_ACTIVE) {
+                    if (!TextUtils.isEmpty(info.mDescription)) {
+                        builder.setContentText(info.mDescription);
+                    } else {
+                        builder.setContentText(remainingText);
+                    }
+                    builder.setContentInfo(percentText);
+
+                } else if (type == TYPE_WAITING) {
+                    builder.setContentText(
+                            res.getString(R.string.notification_need_wifi_for_size));
+
+                } else if (type == TYPE_COMPLETE) {
+                    if (Downloads.Impl.isStatusError(info.mStatus)) {
+                        builder.setContentText(res.getText(R.string.notification_download_failed));
+                    } else if (Downloads.Impl.isStatusSuccess(info.mStatus)) {
+                        builder.setContentText(
+                                res.getText(R.string.notification_download_complete));
+                    }
+                }
+
+                notif = builder.build();
+
+            } else {
+                final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
+
+                for (DownloadInfo info : cluster) {
+                    inboxStyle.addLine(getDownloadTitle(res, info));
+                }
+
+                if (type == TYPE_ACTIVE) {
+                    builder.setContentTitle(res.getQuantityString(
+                            R.plurals.notif_summary_active, cluster.size(), cluster.size()));
+                    builder.setContentText(remainingText);
+                    builder.setContentInfo(percentText);
+                    inboxStyle.setSummaryText(remainingText);
+
+                } else if (type == TYPE_WAITING) {
+                    builder.setContentTitle(res.getQuantityString(
+                            R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
+                    builder.setContentText(
+                            res.getString(R.string.notification_need_wifi_for_size));
+                    inboxStyle.setSummaryText(
+                            res.getString(R.string.notification_need_wifi_for_size));
+                }
+
+                notif = inboxStyle.build();
+            }
+
+            mNotifManager.notify(tag, 0, notif);
+        }
+
+        // Remove stale tags that weren't renewed
+        final Iterator<String> it = mActiveNotifs.keySet().iterator();
+        while (it.hasNext()) {
+            final String tag = it.next();
+            if (!clustered.containsKey(tag)) {
+                mNotifManager.cancel(tag, 0);
+                it.remove();
+            }
+        }
+    }
+
+    private static CharSequence getDownloadTitle(Resources res, DownloadInfo info) {
+        if (!TextUtils.isEmpty(info.mTitle)) {
+            return info.mTitle;
+        } else {
+            return res.getString(R.string.download_unknown_title);
+        }
+    }
+
+    private long[] getDownloadIds(Collection<DownloadInfo> infos) {
+        final long[] ids = new long[infos.size()];
+        int i = 0;
+        for (DownloadInfo info : infos) {
+            ids[i++] = info.mId;
+        }
+        return ids;
+    }
+
+    /**
+     * Build tag used for collapsing several {@link DownloadInfo} into a single
+     * {@link Notification}.
+     */
+    private static String buildNotificationTag(DownloadInfo info) {
+        if (info.mStatus == Downloads.Impl.STATUS_QUEUED_FOR_WIFI) {
+            return TYPE_WAITING + ":" + info.mPackage;
+        } else if (isActiveAndVisible(info)) {
+            return TYPE_ACTIVE + ":" + info.mPackage;
+        } else if (isCompleteAndVisible(info)) {
+            // Complete downloads always have unique notifs
+            return TYPE_COMPLETE + ":" + info.mId;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Return the cluster type of the given tag, as created by
+     * {@link #buildNotificationTag(DownloadInfo)}.
+     */
+    private static int getNotificationTagType(String tag) {
+        return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
+    }
+
+    private static boolean isActiveAndVisible(DownloadInfo download) {
+        return Downloads.Impl.isStatusInformational(download.mStatus) &&
+                (download.mVisibility == VISIBILITY_VISIBLE
+                || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
+    }
+
+    private static boolean isCompleteAndVisible(DownloadInfo download) {
+        return Downloads.Impl.isStatusCompleted(download.mStatus) &&
+                (download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
+                || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
+    }
+}
diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java
index 40ebd2b..c554e41 100644
--- a/src/com/android/providers/downloads/DownloadProvider.java
+++ b/src/com/android/providers/downloads/DownloadProvider.java
@@ -667,15 +667,10 @@
         Context context = getContext();
         if (values.getAsInteger(Downloads.Impl.COLUMN_DESTINATION) ==
                 Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD) {
-            // don't start downloadservice because it has nothing to do in this case.
-            // but does a completion notification need to be sent?
+            // When notification is requested, kick off service to process all
+            // relevant downloads.
             if (Downloads.Impl.isNotificationToBeDisplayed(vis)) {
-                DownloadNotification notifier = new DownloadNotification(context, mSystemFacade);
-                notifier.notificationForCompletedDownload(rowID,
-                        values.getAsString(Downloads.Impl.COLUMN_TITLE),
-                        Downloads.Impl.STATUS_SUCCESS,
-                        Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD,
-                        lastMod);
+                context.startService(new Intent(context, DownloadService.class));
             }
         } else {
             context.startService(new Intent(context, DownloadService.class));
diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java
index 7469508..cbc963c 100644
--- a/src/com/android/providers/downloads/DownloadReceiver.java
+++ b/src/com/android/providers/downloads/DownloadReceiver.java
@@ -16,8 +16,10 @@
 
 package com.android.providers.downloads;
 
+import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
+import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
+
 import android.app.DownloadManager;
-import android.app.NotificationManager;
 import android.content.ActivityNotFoundException;
 import android.content.BroadcastReceiver;
 import android.content.ContentUris;
@@ -28,9 +30,12 @@
 import android.net.ConnectivityManager;
 import android.net.NetworkInfo;
 import android.net.Uri;
-import android.provider.BaseColumns;
+import android.os.Handler;
+import android.os.HandlerThread;
 import android.provider.Downloads;
+import android.text.TextUtils;
 import android.util.Log;
+import android.widget.Toast;
 
 import com.google.common.annotations.VisibleForTesting;
 
@@ -38,11 +43,21 @@
  * Receives system broadcasts (boot, network connectivity)
  */
 public class DownloadReceiver extends BroadcastReceiver {
+    private static final String TAG = "DownloadReceiver";
+
+    private static Handler sAsyncHandler;
+
+    static {
+        final HandlerThread thread = new HandlerThread(TAG);
+        thread.start();
+        sAsyncHandler = new Handler(thread.getLooper());
+    }
+
     @VisibleForTesting
     SystemFacade mSystemFacade = null;
 
     @Override
-    public void onReceive(Context context, Intent intent) {
+    public void onReceive(final Context context, final Intent intent) {
         if (mSystemFacade == null) {
             mSystemFacade = new RealSystemFacade(context);
         }
@@ -72,7 +87,20 @@
         } else if (action.equals(Constants.ACTION_OPEN)
                 || action.equals(Constants.ACTION_LIST)
                 || action.equals(Constants.ACTION_HIDE)) {
-            handleNotificationBroadcast(context, intent);
+
+            final PendingResult result = goAsync();
+            if (result == null) {
+                // TODO: remove this once test is refactored
+                handleNotificationBroadcast(context, intent);
+            } else {
+                sAsyncHandler.post(new Runnable() {
+                        @Override
+                    public void run() {
+                        handleNotificationBroadcast(context, intent);
+                        result.finish();
+                    }
+                });
+            }
         }
     }
 
@@ -80,58 +108,49 @@
      * Handle any broadcast related to a system notification.
      */
     private void handleNotificationBroadcast(Context context, Intent intent) {
-        Uri uri = intent.getData();
-        String action = intent.getAction();
-        if (Constants.LOGVV) {
-            if (action.equals(Constants.ACTION_OPEN)) {
-                Log.v(Constants.TAG, "Receiver open for " + uri);
-            } else if (action.equals(Constants.ACTION_LIST)) {
-                Log.v(Constants.TAG, "Receiver list for " + uri);
-            } else { // ACTION_HIDE
-                Log.v(Constants.TAG, "Receiver hide for " + uri);
-            }
-        }
+        final String action = intent.getAction();
+        if (Constants.ACTION_LIST.equals(action)) {
+            final long[] ids = intent.getLongArrayExtra(
+                    DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS);
+            sendNotificationClickedIntent(context, ids);
 
-        Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
-        if (cursor == null) {
-            return;
-        }
-        try {
-            if (!cursor.moveToFirst()) {
-                return;
-            }
+        } else if (Constants.ACTION_OPEN.equals(action)) {
+            final long id = ContentUris.parseId(intent.getData());
+            openDownload(context, id);
+            hideNotification(context, id);
 
-            if (action.equals(Constants.ACTION_OPEN)) {
-                openDownload(context, cursor);
-                hideNotification(context, uri, cursor);
-            } else if (action.equals(Constants.ACTION_LIST)) {
-                sendNotificationClickedIntent(intent, cursor);
-            } else { // ACTION_HIDE
-                hideNotification(context, uri, cursor);
-            }
-        } finally {
-            cursor.close();
+        } else if (Constants.ACTION_HIDE.equals(action)) {
+            final long id = ContentUris.parseId(intent.getData());
+            hideNotification(context, id);
         }
     }
 
     /**
-     * Hide a system notification for a download.
-     * @param uri URI to update the download
-     * @param cursor Cursor for reading the download's fields
+     * Mark the given {@link DownloadManager#COLUMN_ID} as being acknowledged by
+     * user so it's not renewed later.
      */
-    private void hideNotification(Context context, Uri uri, Cursor cursor) {
-        final NotificationManager notifManager = (NotificationManager) context.getSystemService(
-                Context.NOTIFICATION_SERVICE);
-        notifManager.cancel((int) ContentUris.parseId(uri));
+    private void hideNotification(Context context, long id) {
+        final int status;
+        final int visibility;
 
-        int statusColumn = cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_STATUS);
-        int status = cursor.getInt(statusColumn);
-        int visibilityColumn =
-                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_VISIBILITY);
-        int visibility = cursor.getInt(visibilityColumn);
-        if (Downloads.Impl.isStatusCompleted(status)
-                && visibility == Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) {
-            ContentValues values = new ContentValues();
+        final Uri uri = ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
+        final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
+        try {
+            if (cursor.moveToFirst()) {
+                status = getInt(cursor, Downloads.Impl.COLUMN_STATUS);
+                visibility = getInt(cursor, Downloads.Impl.COLUMN_VISIBILITY);
+            } else {
+                Log.w(TAG, "Missing details for download " + id);
+                return;
+            }
+        } finally {
+            cursor.close();
+        }
+
+        if (Downloads.Impl.isStatusCompleted(status) &&
+                (visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
+                || visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)) {
+            final ContentValues values = new ContentValues();
             values.put(Downloads.Impl.COLUMN_VISIBILITY,
                     Downloads.Impl.VISIBILITY_VISIBLE);
             context.getContentResolver().update(uri, values, null, null);
@@ -139,69 +158,84 @@
     }
 
     /**
-     * Open the download that cursor is currently pointing to, since it's completed notification
-     * has been clicked.
+     * Start activity to display the file represented by the given
+     * {@link DownloadManager#COLUMN_ID}.
      */
-    private void openDownload(Context context, Cursor cursor) {
-        final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BaseColumns._ID));
+    private void openDownload(Context context, long id) {
         final Intent intent = OpenHelper.buildViewIntent(context, id);
         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
         try {
             context.startActivity(intent);
         } catch (ActivityNotFoundException ex) {
             Log.d(Constants.TAG, "no activity for " + intent, ex);
+            Toast.makeText(context, R.string.download_no_application_title, Toast.LENGTH_LONG)
+                    .show();
         }
     }
 
     /**
      * Notify the owner of a running download that its notification was clicked.
-     * @param intent the broadcast intent sent by the notification manager
-     * @param cursor Cursor for reading the download's fields
      */
-    private void sendNotificationClickedIntent(Intent intent, Cursor cursor) {
-        String pckg = cursor.getString(
-                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE));
-        if (pckg == null) {
-            return;
+    private void sendNotificationClickedIntent(Context context, long[] ids) {
+        final String packageName;
+        final String clazz;
+        final boolean isPublicApi;
+
+        final Uri uri = ContentUris.withAppendedId(
+                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, ids[0]);
+        final Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
+        try {
+            if (cursor.moveToFirst()) {
+                packageName = getString(cursor, Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE);
+                clazz = getString(cursor, Downloads.Impl.COLUMN_NOTIFICATION_CLASS);
+                isPublicApi = getInt(cursor, Downloads.Impl.COLUMN_IS_PUBLIC_API) != 0;
+            } else {
+                Log.w(TAG, "Missing details for download " + ids[0]);
+                return;
+            }
+        } finally {
+            cursor.close();
         }
 
-        String clazz = cursor.getString(
-                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_NOTIFICATION_CLASS));
-        boolean isPublicApi =
-                cursor.getInt(cursor.getColumnIndex(Downloads.Impl.COLUMN_IS_PUBLIC_API)) != 0;
+        if (TextUtils.isEmpty(packageName)) {
+            Log.w(TAG, "Missing package; skipping broadcast");
+            return;
+        }
 
         Intent appIntent = null;
         if (isPublicApi) {
             appIntent = new Intent(DownloadManager.ACTION_NOTIFICATION_CLICKED);
-            appIntent.setPackage(pckg);
-            // send id of the items clicked on.
-            if (intent.getBooleanExtra("multiple", false)) {
-                // broadcast received saying click occurred on a notification with multiple titles.
-                // don't include any ids at all - let the caller query all downloads belonging to it
-                // TODO modify the broadcast to include ids of those multiple notifications.
-            } else {
-                appIntent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
-                        new long[] {
-                                cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.Impl._ID))});
-            }
+            appIntent.setPackage(packageName);
+            appIntent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, ids);
+
         } else { // legacy behavior
-            if (clazz == null) {
+            if (TextUtils.isEmpty(clazz)) {
+                Log.w(TAG, "Missing class; skipping broadcast");
                 return;
             }
+
             appIntent = new Intent(DownloadManager.ACTION_NOTIFICATION_CLICKED);
-            appIntent.setClassName(pckg, clazz);
-            if (intent.getBooleanExtra("multiple", true)) {
-                appIntent.setData(Downloads.Impl.CONTENT_URI);
+            appIntent.setClassName(packageName, clazz);
+            appIntent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, ids);
+
+            if (ids.length == 1) {
+                appIntent.setData(uri);
             } else {
-                long downloadId = cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.Impl._ID));
-                appIntent.setData(
-                        ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, downloadId));
+                appIntent.setData(Downloads.Impl.CONTENT_URI);
             }
         }
 
         mSystemFacade.sendBroadcast(appIntent);
     }
 
+    private static String getString(Cursor cursor, String col) {
+        return cursor.getString(cursor.getColumnIndexOrThrow(col));
+    }
+
+    private static int getInt(Cursor cursor, String col) {
+        return cursor.getInt(cursor.getColumnIndexOrThrow(col));
+    }
+
     private void startService(Context context) {
         context.startService(new Intent(context, DownloadService.class));
     }
diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java
index 8380830..0a16a7d 100644
--- a/src/com/android/providers/downloads/DownloadService.java
+++ b/src/com/android/providers/downloads/DownloadService.java
@@ -19,7 +19,6 @@
 import static com.android.providers.downloads.Constants.TAG;
 
 import android.app.AlarmManager;
-import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.app.Service;
 import android.content.ComponentName;
@@ -65,8 +64,7 @@
     private DownloadManagerContentObserver mObserver;
 
     /** Class to handle Notification Manager updates */
-    private DownloadNotification mNotifier;
-    private NotificationManager mNotifManager;
+    private DownloadNotifier mNotifier;
 
     /**
      * The Service's view of the list of downloads, mapping download IDs to the corresponding info
@@ -222,9 +220,7 @@
         mMediaScannerConnecting = false;
         mMediaScannerConnection = new MediaScannerConnection();
 
-        mNotifier = new DownloadNotification(this, mSystemFacade);
-        mNotifManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
-        mNotifManager.cancelAll();
+        mNotifier = new DownloadNotifier(this);
 
         mStorageManager = StorageManager.getInstance(getApplicationContext());
         updateFromProvider();
@@ -359,7 +355,7 @@
                             }
                         }
                     }
-                    mNotifier.updateNotification(mDownloads.values());
+                    mNotifier.updateWith(mDownloads.values());
                     if (mustScan) {
                         bindMediaScanner();
                     } else {
@@ -459,18 +455,6 @@
             Log.v(Constants.TAG, "processing updated download " + info.mId +
                     ", status: " + info.mStatus);
         }
-
-        boolean lostVisibility =
-                oldVisibility == Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
-                && info.mVisibility != Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
-                && Downloads.Impl.isStatusCompleted(info.mStatus);
-        boolean justCompleted =
-                !Downloads.Impl.isStatusCompleted(oldStatus)
-                && Downloads.Impl.isStatusCompleted(info.mStatus);
-        if (lostVisibility || justCompleted) {
-            mNotifManager.cancel((int) info.mId);
-        }
-
         info.startIfReady(now, mStorageManager);
     }
 
@@ -488,7 +472,6 @@
             }
             new File(info.mFileName).delete();
         }
-        mNotifManager.cancel((int) info.mId);
         mDownloads.remove(info.mId);
     }
 
diff --git a/tests/src/com/android/providers/downloads/FakeSystemFacade.java b/tests/src/com/android/providers/downloads/FakeSystemFacade.java
index 6898efd..481b5cb 100644
--- a/tests/src/com/android/providers/downloads/FakeSystemFacade.java
+++ b/tests/src/com/android/providers/downloads/FakeSystemFacade.java
@@ -4,6 +4,7 @@
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.net.ConnectivityManager;
 import android.net.NetworkInfo;
+import android.net.NetworkInfo.DetailedState;
 
 import java.util.ArrayList;
 import java.util.LinkedList;
@@ -36,7 +37,9 @@
         if (mActiveNetworkType == null) {
             return null;
         } else {
-            return new NetworkInfo(mActiveNetworkType, 0, null, null);
+            final NetworkInfo info = new NetworkInfo(mActiveNetworkType, 0, null, null);
+            info.setDetailedState(DetailedState.CONNECTED, null, null);
+            return info;
         }
     }
 
diff --git a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java
index 34a69df..2661a1f 100644
--- a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java
+++ b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java
@@ -17,6 +17,7 @@
 package com.android.providers.downloads;
 
 import static com.google.testing.littlemock.LittleMock.anyInt;
+import static com.google.testing.littlemock.LittleMock.anyString;
 import static com.google.testing.littlemock.LittleMock.atLeastOnce;
 import static com.google.testing.littlemock.LittleMock.isA;
 import static com.google.testing.littlemock.LittleMock.never;
@@ -449,6 +450,8 @@
         receiver.mSystemFacade = mSystemFacade;
         Intent intent = new Intent(Constants.ACTION_LIST);
         intent.setData(Uri.parse(Downloads.Impl.CONTENT_URI + "/" + download.mId));
+        intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
+                new long[] { download.mId });
         receiver.onReceive(mContext, intent);
 
         assertEquals(1, mSystemFacade.mBroadcastsSent.size());
@@ -523,7 +526,7 @@
         download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL);
         runService();
 
-        verify(mNotifManager, never()).notify(anyInt(), isA(Notification.class));
+        verify(mNotifManager, never()).notify(anyString(), anyInt(), isA(Notification.class));
         // TODO: verify that it never cancels
     }
 
@@ -536,8 +539,8 @@
         runService();
 
         // TODO: verify different notif types with tags
-        verify(mNotifManager, atLeastOnce()).notify(anyInt(), isA(Notification.class));
-        verify(mNotifManager, times(1)).cancel(anyInt());
+        verify(mNotifManager, atLeastOnce()).notify(anyString(), anyInt(), isA(Notification.class));
+        verify(mNotifManager, times(1)).cancel(anyString(), anyInt());
     }
 
     public void testNotificationVisibleComplete() throws Exception {
@@ -549,8 +552,8 @@
         runService();
 
         // TODO: verify different notif types with tags
-        verify(mNotifManager, atLeastOnce()).notify(anyInt(), isA(Notification.class));
-        verify(mNotifManager, times(1)).cancel(anyInt());
+        verify(mNotifManager, atLeastOnce()).notify(anyString(), anyInt(), isA(Notification.class));
+        verify(mNotifManager, times(1)).cancel(anyString(), anyInt());
     }
 
     public void testRetryAfter() throws Exception {