• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.providers.downloads;
18 
19 import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE;
20 import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
21 import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
22 import static android.provider.Downloads.Impl.STATUS_QUEUED_FOR_WIFI;
23 import static android.provider.Downloads.Impl.STATUS_RUNNING;
24 
25 import static com.android.providers.downloads.Constants.TAG;
26 
27 import android.app.DownloadManager;
28 import android.app.Notification;
29 import android.app.NotificationManager;
30 import android.app.PendingIntent;
31 import android.content.ContentUris;
32 import android.content.Context;
33 import android.content.Intent;
34 import android.content.res.Resources;
35 import android.database.Cursor;
36 import android.net.Uri;
37 import android.os.SystemClock;
38 import android.provider.Downloads;
39 import android.service.notification.StatusBarNotification;
40 import android.text.TextUtils;
41 import android.text.format.DateUtils;
42 import android.util.ArrayMap;
43 import android.util.IntArray;
44 import android.util.Log;
45 import android.util.LongSparseLongArray;
46 
47 import com.android.internal.util.ArrayUtils;
48 
49 import java.text.NumberFormat;
50 
51 import 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  */
58 public 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 
DownloadNotifier(Context context)90     public DownloadNotifier(Context context) {
91         mContext = context;
92         mNotifManager = (NotificationManager) context.getSystemService(
93                 Context.NOTIFICATION_SERVICE);
94     }
95 
init()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      */
notifyDownloadSpeed(long id, long bytesPerSecond)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 
update()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 
updateWithLocked(Cursor cursor)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 
getDownloadTitle(Resources res, Cursor cursor)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 
getDownloadIds(Cursor cursor, IntArray cluster)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 
dumpSpeeds()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      */
buildNotificationTag(Cursor cursor)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      */
getNotificationTagType(String tag)433     private static int getNotificationTagType(String tag) {
434         return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
435     }
436 
isQueuedAndVisible(int status, int visibility)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 
isActiveAndVisible(int status, int visibility)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 
isCompleteAndVisible(int status, int visibility)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