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