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