1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.providers.downloads;
18
19import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE;
20import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
21import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
22import static android.provider.Downloads.Impl.STATUS_RUNNING;
23import static com.android.providers.downloads.Constants.TAG;
24
25import android.app.DownloadManager;
26import android.app.Notification;
27import android.app.NotificationManager;
28import android.app.PendingIntent;
29import android.content.ContentUris;
30import android.content.Context;
31import android.content.Intent;
32import android.content.res.Resources;
33import android.net.Uri;
34import android.os.SystemClock;
35import android.provider.Downloads;
36import android.text.TextUtils;
37import android.text.format.DateUtils;
38import android.util.Log;
39import android.util.LongSparseLongArray;
40
41import com.google.common.collect.ArrayListMultimap;
42import com.google.common.collect.Maps;
43import com.google.common.collect.Multimap;
44
45import java.util.Collection;
46import java.util.HashMap;
47import java.util.Iterator;
48
49import javax.annotation.concurrent.GuardedBy;
50
51/**
52 * Update {@link NotificationManager} to reflect current {@link DownloadInfo}
53 * states. Collapses similar downloads into a single notification, and builds
54 * {@link PendingIntent} that launch towards {@link DownloadReceiver}.
55 */
56public class DownloadNotifier {
57
58    private static final int TYPE_ACTIVE = 1;
59    private static final int TYPE_WAITING = 2;
60    private static final int TYPE_COMPLETE = 3;
61
62    private final Context mContext;
63    private final NotificationManager mNotifManager;
64
65    /**
66     * Currently active notifications, mapped from clustering tag to timestamp
67     * when first shown.
68     *
69     * @see #buildNotificationTag(DownloadInfo)
70     */
71    @GuardedBy("mActiveNotifs")
72    private final HashMap<String, Long> mActiveNotifs = Maps.newHashMap();
73
74    /**
75     * Current speed of active downloads, mapped from {@link DownloadInfo#mId}
76     * to speed in bytes per second.
77     */
78    @GuardedBy("mDownloadSpeed")
79    private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray();
80
81    /**
82     * Last time speed was reproted, mapped from {@link DownloadInfo#mId} to
83     * {@link SystemClock#elapsedRealtime()}.
84     */
85    @GuardedBy("mDownloadSpeed")
86    private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray();
87
88    public DownloadNotifier(Context context) {
89        mContext = context;
90        mNotifManager = (NotificationManager) context.getSystemService(
91                Context.NOTIFICATION_SERVICE);
92    }
93
94    public void cancelAll() {
95        mNotifManager.cancelAll();
96    }
97
98    /**
99     * Notify the current speed of an active download, used for calculating
100     * estimated remaining time.
101     */
102    public void notifyDownloadSpeed(long id, long bytesPerSecond) {
103        synchronized (mDownloadSpeed) {
104            if (bytesPerSecond != 0) {
105                mDownloadSpeed.put(id, bytesPerSecond);
106                mDownloadTouch.put(id, SystemClock.elapsedRealtime());
107            } else {
108                mDownloadSpeed.delete(id);
109                mDownloadTouch.delete(id);
110            }
111        }
112    }
113
114    /**
115     * Update {@link NotificationManager} to reflect the given set of
116     * {@link DownloadInfo}, adding, collapsing, and removing as needed.
117     */
118    public void updateWith(Collection<DownloadInfo> downloads) {
119        synchronized (mActiveNotifs) {
120            updateWithLocked(downloads);
121        }
122    }
123
124    private void updateWithLocked(Collection<DownloadInfo> downloads) {
125        final Resources res = mContext.getResources();
126
127        // Cluster downloads together
128        final Multimap<String, DownloadInfo> clustered = ArrayListMultimap.create();
129        for (DownloadInfo info : downloads) {
130            final String tag = buildNotificationTag(info);
131            if (tag != null) {
132                clustered.put(tag, info);
133            }
134        }
135
136        // Build notification for each cluster
137        for (String tag : clustered.keySet()) {
138            final int type = getNotificationTagType(tag);
139            final Collection<DownloadInfo> cluster = clustered.get(tag);
140
141            final Notification.Builder builder = new Notification.Builder(mContext);
142            builder.setColor(res.getColor(
143                    com.android.internal.R.color.system_notification_accent_color));
144
145            // Use time when cluster was first shown to avoid shuffling
146            final long firstShown;
147            if (mActiveNotifs.containsKey(tag)) {
148                firstShown = mActiveNotifs.get(tag);
149            } else {
150                firstShown = System.currentTimeMillis();
151                mActiveNotifs.put(tag, firstShown);
152            }
153            builder.setWhen(firstShown);
154
155            // Show relevant icon
156            if (type == TYPE_ACTIVE) {
157                builder.setSmallIcon(android.R.drawable.stat_sys_download);
158            } else if (type == TYPE_WAITING) {
159                builder.setSmallIcon(android.R.drawable.stat_sys_warning);
160            } else if (type == TYPE_COMPLETE) {
161                builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
162            }
163
164            // Build action intents
165            if (type == TYPE_ACTIVE || type == TYPE_WAITING) {
166                // build a synthetic uri for intent identification purposes
167                final Uri uri = new Uri.Builder().scheme("active-dl").appendPath(tag).build();
168                final Intent intent = new Intent(Constants.ACTION_LIST,
169                        uri, mContext, DownloadReceiver.class);
170                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
171                        getDownloadIds(cluster));
172                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
173                        0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
174                builder.setOngoing(true);
175
176            } else if (type == TYPE_COMPLETE) {
177                final DownloadInfo info = cluster.iterator().next();
178                final Uri uri = ContentUris.withAppendedId(
179                        Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, info.mId);
180                builder.setAutoCancel(true);
181
182                final String action;
183                if (Downloads.Impl.isStatusError(info.mStatus)) {
184                    action = Constants.ACTION_LIST;
185                } else {
186                    if (info.mDestination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) {
187                        action = Constants.ACTION_OPEN;
188                    } else {
189                        action = Constants.ACTION_LIST;
190                    }
191                }
192
193                final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
194                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
195                        getDownloadIds(cluster));
196                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
197                        0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
198
199                final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
200                        uri, mContext, DownloadReceiver.class);
201                builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent, 0));
202            }
203
204            // Calculate and show progress
205            String remainingText = null;
206            String percentText = null;
207            if (type == TYPE_ACTIVE) {
208                long current = 0;
209                long total = 0;
210                long speed = 0;
211                synchronized (mDownloadSpeed) {
212                    for (DownloadInfo info : cluster) {
213                        if (info.mTotalBytes != -1) {
214                            current += info.mCurrentBytes;
215                            total += info.mTotalBytes;
216                            speed += mDownloadSpeed.get(info.mId);
217                        }
218                    }
219                }
220
221                if (total > 0) {
222                    final int percent = (int) ((current * 100) / total);
223                    percentText = res.getString(R.string.download_percent, percent);
224
225                    if (speed > 0) {
226                        final long remainingMillis = ((total - current) * 1000) / speed;
227                        remainingText = res.getString(R.string.download_remaining,
228                                DateUtils.formatDuration(remainingMillis));
229                    }
230
231                    builder.setProgress(100, percent, false);
232                } else {
233                    builder.setProgress(100, 0, true);
234                }
235            }
236
237            // Build titles and description
238            final Notification notif;
239            if (cluster.size() == 1) {
240                final DownloadInfo info = cluster.iterator().next();
241
242                builder.setContentTitle(getDownloadTitle(res, info));
243
244                if (type == TYPE_ACTIVE) {
245                    if (!TextUtils.isEmpty(info.mDescription)) {
246                        builder.setContentText(info.mDescription);
247                    } else {
248                        builder.setContentText(remainingText);
249                    }
250                    builder.setContentInfo(percentText);
251
252                } else if (type == TYPE_WAITING) {
253                    builder.setContentText(
254                            res.getString(R.string.notification_need_wifi_for_size));
255
256                } else if (type == TYPE_COMPLETE) {
257                    if (Downloads.Impl.isStatusError(info.mStatus)) {
258                        builder.setContentText(res.getText(R.string.notification_download_failed));
259                    } else if (Downloads.Impl.isStatusSuccess(info.mStatus)) {
260                        builder.setContentText(
261                                res.getText(R.string.notification_download_complete));
262                    }
263                }
264
265                notif = builder.build();
266
267            } else {
268                final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
269
270                for (DownloadInfo info : cluster) {
271                    inboxStyle.addLine(getDownloadTitle(res, info));
272                }
273
274                if (type == TYPE_ACTIVE) {
275                    builder.setContentTitle(res.getQuantityString(
276                            R.plurals.notif_summary_active, cluster.size(), cluster.size()));
277                    builder.setContentText(remainingText);
278                    builder.setContentInfo(percentText);
279                    inboxStyle.setSummaryText(remainingText);
280
281                } else if (type == TYPE_WAITING) {
282                    builder.setContentTitle(res.getQuantityString(
283                            R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
284                    builder.setContentText(
285                            res.getString(R.string.notification_need_wifi_for_size));
286                    inboxStyle.setSummaryText(
287                            res.getString(R.string.notification_need_wifi_for_size));
288                }
289
290                notif = inboxStyle.build();
291            }
292
293            mNotifManager.notify(tag, 0, notif);
294        }
295
296        // Remove stale tags that weren't renewed
297        final Iterator<String> it = mActiveNotifs.keySet().iterator();
298        while (it.hasNext()) {
299            final String tag = it.next();
300            if (!clustered.containsKey(tag)) {
301                mNotifManager.cancel(tag, 0);
302                it.remove();
303            }
304        }
305    }
306
307    private static CharSequence getDownloadTitle(Resources res, DownloadInfo info) {
308        if (!TextUtils.isEmpty(info.mTitle)) {
309            return info.mTitle;
310        } else {
311            return res.getString(R.string.download_unknown_title);
312        }
313    }
314
315    private long[] getDownloadIds(Collection<DownloadInfo> infos) {
316        final long[] ids = new long[infos.size()];
317        int i = 0;
318        for (DownloadInfo info : infos) {
319            ids[i++] = info.mId;
320        }
321        return ids;
322    }
323
324    public void dumpSpeeds() {
325        synchronized (mDownloadSpeed) {
326            for (int i = 0; i < mDownloadSpeed.size(); i++) {
327                final long id = mDownloadSpeed.keyAt(i);
328                final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
329                Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
330                        + delta + "ms ago");
331            }
332        }
333    }
334
335    /**
336     * Build tag used for collapsing several {@link DownloadInfo} into a single
337     * {@link Notification}.
338     */
339    private static String buildNotificationTag(DownloadInfo info) {
340        if (info.mStatus == Downloads.Impl.STATUS_QUEUED_FOR_WIFI) {
341            return TYPE_WAITING + ":" + info.mPackage;
342        } else if (isActiveAndVisible(info)) {
343            return TYPE_ACTIVE + ":" + info.mPackage;
344        } else if (isCompleteAndVisible(info)) {
345            // Complete downloads always have unique notifs
346            return TYPE_COMPLETE + ":" + info.mId;
347        } else {
348            return null;
349        }
350    }
351
352    /**
353     * Return the cluster type of the given tag, as created by
354     * {@link #buildNotificationTag(DownloadInfo)}.
355     */
356    private static int getNotificationTagType(String tag) {
357        return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
358    }
359
360    private static boolean isActiveAndVisible(DownloadInfo download) {
361        return download.mStatus == STATUS_RUNNING &&
362                (download.mVisibility == VISIBILITY_VISIBLE
363                || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
364    }
365
366    private static boolean isCompleteAndVisible(DownloadInfo download) {
367        return Downloads.Impl.isStatusCompleted(download.mStatus) &&
368                (download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
369                || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
370    }
371}
372