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