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_QUEUED_FOR_WIFI;
23import static android.provider.Downloads.Impl.STATUS_RUNNING;
24
25import static com.android.providers.downloads.Constants.TAG;
26
27import android.app.DownloadManager;
28import android.app.Notification;
29import android.app.NotificationManager;
30import android.app.PendingIntent;
31import android.content.ContentUris;
32import android.content.Context;
33import android.content.Intent;
34import android.content.res.Resources;
35import android.database.Cursor;
36import android.net.Uri;
37import android.os.SystemClock;
38import android.provider.Downloads;
39import android.service.notification.StatusBarNotification;
40import android.text.TextUtils;
41import android.text.format.DateUtils;
42import android.util.ArrayMap;
43import android.util.IntArray;
44import android.util.Log;
45import android.util.LongSparseLongArray;
46
47import com.android.internal.util.ArrayUtils;
48
49import java.text.NumberFormat;
50
51import javax.annotation.concurrent.GuardedBy;
52
53/**
54 * Update {@link NotificationManager} to reflect current download states.
55 * Collapses similar downloads into a single notification, and builds
56 * {@link PendingIntent} that launch towards {@link DownloadReceiver}.
57 */
58public class DownloadNotifier {
59
60    private static final int TYPE_ACTIVE = 1;
61    private static final int TYPE_WAITING = 2;
62    private static final int TYPE_COMPLETE = 3;
63
64    private final Context mContext;
65    private final NotificationManager mNotifManager;
66
67    /**
68     * Currently active notifications, mapped from clustering tag to timestamp
69     * when first shown.
70     *
71     * @see #buildNotificationTag(Cursor)
72     */
73    @GuardedBy("mActiveNotifs")
74    private final ArrayMap<String, Long> mActiveNotifs = new ArrayMap<>();
75
76    /**
77     * Current speed of active downloads, mapped from download ID to speed in
78     * bytes per second.
79     */
80    @GuardedBy("mDownloadSpeed")
81    private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray();
82
83    /**
84     * Last time speed was reproted, mapped from download ID to
85     * {@link SystemClock#elapsedRealtime()}.
86     */
87    @GuardedBy("mDownloadSpeed")
88    private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray();
89
90    public DownloadNotifier(Context context) {
91        mContext = context;
92        mNotifManager = (NotificationManager) context.getSystemService(
93                Context.NOTIFICATION_SERVICE);
94    }
95
96    public void init() {
97        synchronized (mActiveNotifs) {
98            mActiveNotifs.clear();
99            final StatusBarNotification[] notifs = mNotifManager.getActiveNotifications();
100            if (!ArrayUtils.isEmpty(notifs)) {
101                for (StatusBarNotification notif : notifs) {
102                    mActiveNotifs.put(notif.getTag(), notif.getPostTime());
103                }
104            }
105        }
106    }
107
108    /**
109     * Notify the current speed of an active download, used for calculating
110     * estimated remaining time.
111     */
112    public void notifyDownloadSpeed(long id, long bytesPerSecond) {
113        synchronized (mDownloadSpeed) {
114            if (bytesPerSecond != 0) {
115                mDownloadSpeed.put(id, bytesPerSecond);
116                mDownloadTouch.put(id, SystemClock.elapsedRealtime());
117            } else {
118                mDownloadSpeed.delete(id);
119                mDownloadTouch.delete(id);
120            }
121        }
122    }
123
124    private interface UpdateQuery {
125        final String[] PROJECTION = new String[] {
126                Downloads.Impl._ID,
127                Downloads.Impl.COLUMN_STATUS,
128                Downloads.Impl.COLUMN_VISIBILITY,
129                Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE,
130                Downloads.Impl.COLUMN_CURRENT_BYTES,
131                Downloads.Impl.COLUMN_TOTAL_BYTES,
132                Downloads.Impl.COLUMN_DESTINATION,
133                Downloads.Impl.COLUMN_TITLE,
134                Downloads.Impl.COLUMN_DESCRIPTION,
135        };
136
137        final int _ID = 0;
138        final int STATUS = 1;
139        final int VISIBILITY = 2;
140        final int NOTIFICATION_PACKAGE = 3;
141        final int CURRENT_BYTES = 4;
142        final int TOTAL_BYTES = 5;
143        final int DESTINATION = 6;
144        final int TITLE = 7;
145        final int DESCRIPTION = 8;
146    }
147
148    public void update() {
149        try (Cursor cursor = mContext.getContentResolver().query(
150                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, UpdateQuery.PROJECTION,
151                Downloads.Impl.COLUMN_DELETED + " == '0'", null, null)) {
152            synchronized (mActiveNotifs) {
153                updateWithLocked(cursor);
154            }
155        }
156    }
157
158    private void updateWithLocked(Cursor cursor) {
159        final Resources res = mContext.getResources();
160
161        // Cluster downloads together
162        final ArrayMap<String, IntArray> clustered = new ArrayMap<>();
163        while (cursor.moveToNext()) {
164            final String tag = buildNotificationTag(cursor);
165            if (tag != null) {
166                IntArray cluster = clustered.get(tag);
167                if (cluster == null) {
168                    cluster = new IntArray();
169                    clustered.put(tag, cluster);
170                }
171                cluster.add(cursor.getPosition());
172            }
173        }
174
175        // Build notification for each cluster
176        for (int i = 0; i < clustered.size(); i++) {
177            final String tag = clustered.keyAt(i);
178            final IntArray cluster = clustered.valueAt(i);
179            final int type = getNotificationTagType(tag);
180
181            final Notification.Builder builder = new Notification.Builder(mContext);
182            builder.setColor(res.getColor(
183                    com.android.internal.R.color.system_notification_accent_color));
184
185            // Use time when cluster was first shown to avoid shuffling
186            final long firstShown;
187            if (mActiveNotifs.containsKey(tag)) {
188                firstShown = mActiveNotifs.get(tag);
189            } else {
190                firstShown = System.currentTimeMillis();
191                mActiveNotifs.put(tag, firstShown);
192            }
193            builder.setWhen(firstShown);
194
195            // Show relevant icon
196            if (type == TYPE_ACTIVE) {
197                builder.setSmallIcon(android.R.drawable.stat_sys_download);
198            } else if (type == TYPE_WAITING) {
199                builder.setSmallIcon(android.R.drawable.stat_sys_warning);
200            } else if (type == TYPE_COMPLETE) {
201                builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
202            }
203
204            // Build action intents
205            if (type == TYPE_ACTIVE || type == TYPE_WAITING) {
206                final long[] downloadIds = getDownloadIds(cursor, cluster);
207
208                // build a synthetic uri for intent identification purposes
209                final Uri uri = new Uri.Builder().scheme("active-dl").appendPath(tag).build();
210                final Intent intent = new Intent(Constants.ACTION_LIST,
211                        uri, mContext, DownloadReceiver.class);
212                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
213                        downloadIds);
214                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
215                        0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
216                if (type == TYPE_ACTIVE) {
217                    builder.setOngoing(true);
218                }
219
220                // Add a Cancel action
221                final Uri cancelUri = new Uri.Builder().scheme("cancel-dl").appendPath(tag).build();
222                final Intent cancelIntent = new Intent(Constants.ACTION_CANCEL,
223                        cancelUri, mContext, DownloadReceiver.class);
224                cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_IDS, downloadIds);
225                cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_NOTIFICATION_TAG, tag);
226
227                builder.addAction(
228                    android.R.drawable.ic_menu_close_clear_cancel,
229                    res.getString(R.string.button_cancel_download),
230                    PendingIntent.getBroadcast(mContext,
231                            0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT));
232
233            } else if (type == TYPE_COMPLETE) {
234                cursor.moveToPosition(cluster.get(0));
235                final long id = cursor.getLong(UpdateQuery._ID);
236                final int status = cursor.getInt(UpdateQuery.STATUS);
237                final int destination = cursor.getInt(UpdateQuery.DESTINATION);
238
239                final Uri uri = ContentUris.withAppendedId(
240                        Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
241                builder.setAutoCancel(true);
242
243                final String action;
244                if (Downloads.Impl.isStatusError(status)) {
245                    action = Constants.ACTION_LIST;
246                } else {
247                    if (destination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) {
248                        action = Constants.ACTION_OPEN;
249                    } else {
250                        action = Constants.ACTION_LIST;
251                    }
252                }
253
254                final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
255                intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
256                        getDownloadIds(cursor, cluster));
257                builder.setContentIntent(PendingIntent.getBroadcast(mContext,
258                        0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
259
260                final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
261                        uri, mContext, DownloadReceiver.class);
262                builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent, 0));
263            }
264
265            // Calculate and show progress
266            String remainingText = null;
267            String percentText = null;
268            if (type == TYPE_ACTIVE) {
269                long current = 0;
270                long total = 0;
271                long speed = 0;
272                synchronized (mDownloadSpeed) {
273                    for (int j = 0; j < cluster.size(); j++) {
274                        cursor.moveToPosition(cluster.get(j));
275
276                        final long id = cursor.getLong(UpdateQuery._ID);
277                        final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
278                        final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);
279
280                        if (totalBytes != -1) {
281                            current += currentBytes;
282                            total += totalBytes;
283                            speed += mDownloadSpeed.get(id);
284                        }
285                    }
286                }
287
288                if (total > 0) {
289                    percentText =
290                            NumberFormat.getPercentInstance().format((double) current / total);
291
292                    if (speed > 0) {
293                        final long remainingMillis = ((total - current) * 1000) / speed;
294                        remainingText = res.getString(R.string.download_remaining,
295                                DateUtils.formatDuration(remainingMillis));
296                    }
297
298                    final int percent = (int) ((current * 100) / total);
299                    builder.setProgress(100, percent, false);
300                } else {
301                    builder.setProgress(100, 0, true);
302                }
303            }
304
305            // Build titles and description
306            final Notification notif;
307            if (cluster.size() == 1) {
308                cursor.moveToPosition(cluster.get(0));
309                builder.setContentTitle(getDownloadTitle(res, cursor));
310
311                if (type == TYPE_ACTIVE) {
312                    final String description = cursor.getString(UpdateQuery.DESCRIPTION);
313                    if (!TextUtils.isEmpty(description)) {
314                        builder.setContentText(description);
315                    } else {
316                        builder.setContentText(remainingText);
317                    }
318                    builder.setContentInfo(percentText);
319
320                } else if (type == TYPE_WAITING) {
321                    builder.setContentText(
322                            res.getString(R.string.notification_need_wifi_for_size));
323
324                } else if (type == TYPE_COMPLETE) {
325                    final int status = cursor.getInt(UpdateQuery.STATUS);
326                    if (Downloads.Impl.isStatusError(status)) {
327                        builder.setContentText(res.getText(R.string.notification_download_failed));
328                    } else if (Downloads.Impl.isStatusSuccess(status)) {
329                        builder.setContentText(
330                                res.getText(R.string.notification_download_complete));
331                    }
332                }
333
334                notif = builder.build();
335
336            } else {
337                final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
338
339                for (int j = 0; j < cluster.size(); j++) {
340                    cursor.moveToPosition(cluster.get(j));
341                    inboxStyle.addLine(getDownloadTitle(res, cursor));
342                }
343
344                if (type == TYPE_ACTIVE) {
345                    builder.setContentTitle(res.getQuantityString(
346                            R.plurals.notif_summary_active, cluster.size(), cluster.size()));
347                    builder.setContentText(remainingText);
348                    builder.setContentInfo(percentText);
349                    inboxStyle.setSummaryText(remainingText);
350
351                } else if (type == TYPE_WAITING) {
352                    builder.setContentTitle(res.getQuantityString(
353                            R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
354                    builder.setContentText(
355                            res.getString(R.string.notification_need_wifi_for_size));
356                    inboxStyle.setSummaryText(
357                            res.getString(R.string.notification_need_wifi_for_size));
358                }
359
360                notif = inboxStyle.build();
361            }
362
363            mNotifManager.notify(tag, 0, notif);
364        }
365
366        // Remove stale tags that weren't renewed
367        for (int i = 0; i < mActiveNotifs.size();) {
368            final String tag = mActiveNotifs.keyAt(i);
369            if (clustered.containsKey(tag)) {
370                i++;
371            } else {
372                mNotifManager.cancel(tag, 0);
373                mActiveNotifs.removeAt(i);
374            }
375        }
376    }
377
378    private static CharSequence getDownloadTitle(Resources res, Cursor cursor) {
379        final String title = cursor.getString(UpdateQuery.TITLE);
380        if (!TextUtils.isEmpty(title)) {
381            return title;
382        } else {
383            return res.getString(R.string.download_unknown_title);
384        }
385    }
386
387    private long[] getDownloadIds(Cursor cursor, IntArray cluster) {
388        final long[] ids = new long[cluster.size()];
389        for (int i = 0; i < cluster.size(); i++) {
390            cursor.moveToPosition(cluster.get(i));
391            ids[i] = cursor.getLong(UpdateQuery._ID);
392        }
393        return ids;
394    }
395
396    public void dumpSpeeds() {
397        synchronized (mDownloadSpeed) {
398            for (int i = 0; i < mDownloadSpeed.size(); i++) {
399                final long id = mDownloadSpeed.keyAt(i);
400                final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
401                Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
402                        + delta + "ms ago");
403            }
404        }
405    }
406
407    /**
408     * Build tag used for collapsing several downloads into a single
409     * {@link Notification}.
410     */
411    private static String buildNotificationTag(Cursor cursor) {
412        final long id = cursor.getLong(UpdateQuery._ID);
413        final int status = cursor.getInt(UpdateQuery.STATUS);
414        final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
415        final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);
416
417        if (isQueuedAndVisible(status, visibility)) {
418            return TYPE_WAITING + ":" + notifPackage;
419        } else if (isActiveAndVisible(status, visibility)) {
420            return TYPE_ACTIVE + ":" + notifPackage;
421        } else if (isCompleteAndVisible(status, visibility)) {
422            // Complete downloads always have unique notifs
423            return TYPE_COMPLETE + ":" + id;
424        } else {
425            return null;
426        }
427    }
428
429    /**
430     * Return the cluster type of the given tag, as created by
431     * {@link #buildNotificationTag(Cursor)}.
432     */
433    private static int getNotificationTagType(String tag) {
434        return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
435    }
436
437    private static boolean isQueuedAndVisible(int status, int visibility) {
438        return status == STATUS_QUEUED_FOR_WIFI &&
439                (visibility == VISIBILITY_VISIBLE
440                || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
441    }
442
443    private static boolean isActiveAndVisible(int status, int visibility) {
444        return status == STATUS_RUNNING &&
445                (visibility == VISIBILITY_VISIBLE
446                || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
447    }
448
449    private static boolean isCompleteAndVisible(int status, int visibility) {
450        return Downloads.Impl.isStatusCompleted(status) &&
451                (visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
452                || visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
453    }
454}
455