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, PendingIntent.FLAG_UPDATE_CURRENT));
237                 if (type == TYPE_ACTIVE) {
238                     builder.setOngoing(true);
239                 }
240 
241                 // Add a Cancel action
242                 final Uri cancelUri = new Uri.Builder().scheme("cancel-dl").appendPath(tag).build();
243                 final Intent cancelIntent = new Intent(Constants.ACTION_CANCEL,
244                         cancelUri, mContext, DownloadReceiver.class);
245                 cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
246                 cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_IDS, downloadIds);
247                 cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_NOTIFICATION_TAG, tag);
248 
249                 builder.addAction(
250                     android.R.drawable.ic_menu_close_clear_cancel,
251                     res.getString(R.string.button_cancel_download),
252                     PendingIntent.getBroadcast(mContext,
253                             0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT));
254 
255             } else if (type == TYPE_COMPLETE) {
256                 cursor.moveToPosition(cluster.get(0));
257                 final long id = cursor.getLong(UpdateQuery._ID);
258                 final int status = cursor.getInt(UpdateQuery.STATUS);
259                 final int destination = cursor.getInt(UpdateQuery.DESTINATION);
260 
261                 final Uri uri = ContentUris.withAppendedId(
262                         Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
263                 builder.setAutoCancel(true);
264 
265                 final String action;
266                 if (Downloads.Impl.isStatusError(status)) {
267                     action = Constants.ACTION_LIST;
268                 } else {
269                     action = Constants.ACTION_OPEN;
270                 }
271 
272                 final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
273                 intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
274                 intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
275                         getDownloadIds(cursor, cluster));
276                 builder.setContentIntent(PendingIntent.getBroadcast(mContext,
277                         0, intent, PendingIntent.FLAG_UPDATE_CURRENT));
278 
279                 final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
280                         uri, mContext, DownloadReceiver.class);
281                 hideIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
282                 builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent, 0));
283             }
284 
285             // Calculate and show progress
286             String remainingText = null;
287             String percentText = null;
288             if (type == TYPE_ACTIVE) {
289                 long current = 0;
290                 long total = 0;
291                 long speed = 0;
292                 synchronized (mDownloadSpeed) {
293                     for (int j = 0; j < cluster.size(); j++) {
294                         cursor.moveToPosition(cluster.get(j));
295 
296                         final long id = cursor.getLong(UpdateQuery._ID);
297                         final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
298                         final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);
299 
300                         if (totalBytes != -1) {
301                             current += currentBytes;
302                             total += totalBytes;
303                             speed += mDownloadSpeed.get(id);
304                         }
305                     }
306                 }
307 
308                 if (total > 0) {
309                     percentText =
310                             NumberFormat.getPercentInstance().format((double) current / total);
311 
312                     if (speed > 0) {
313                         final long remainingMillis = ((total - current) * 1000) / speed;
314                         remainingText = res.getString(R.string.download_remaining,
315                                 DateUtils.formatDuration(remainingMillis));
316                     }
317 
318                     final int percent = (int) ((current * 100) / total);
319                     builder.setProgress(100, percent, false);
320                 } else {
321                     builder.setProgress(100, 0, true);
322                 }
323             }
324 
325             // Build titles and description
326             final Notification notif;
327             if (cluster.size() == 1) {
328                 cursor.moveToPosition(cluster.get(0));
329                 builder.setContentTitle(getDownloadTitle(res, cursor));
330 
331                 if (type == TYPE_ACTIVE) {
332                     final String description = cursor.getString(UpdateQuery.DESCRIPTION);
333                     if (!TextUtils.isEmpty(description)) {
334                         builder.setContentText(description);
335                     } else {
336                         builder.setContentText(remainingText);
337                     }
338                     builder.setContentInfo(percentText);
339 
340                 } else if (type == TYPE_WAITING) {
341                     builder.setContentText(
342                             res.getString(R.string.notification_need_wifi_for_size));
343 
344                 } else if (type == TYPE_COMPLETE) {
345                     final int status = cursor.getInt(UpdateQuery.STATUS);
346                     if (Downloads.Impl.isStatusError(status)) {
347                         builder.setContentText(res.getText(R.string.notification_download_failed));
348                     } else if (Downloads.Impl.isStatusSuccess(status)) {
349                         builder.setContentText(
350                                 res.getText(R.string.notification_download_complete));
351                     }
352                 }
353 
354                 notif = builder.build();
355 
356             } else {
357                 final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
358 
359                 for (int j = 0; j < cluster.size(); j++) {
360                     cursor.moveToPosition(cluster.get(j));
361                     inboxStyle.addLine(getDownloadTitle(res, cursor));
362                 }
363 
364                 if (type == TYPE_ACTIVE) {
365                     builder.setContentTitle(res.getQuantityString(
366                             R.plurals.notif_summary_active, cluster.size(), cluster.size()));
367                     builder.setContentText(remainingText);
368                     builder.setContentInfo(percentText);
369                     inboxStyle.setSummaryText(remainingText);
370 
371                 } else if (type == TYPE_WAITING) {
372                     builder.setContentTitle(res.getQuantityString(
373                             R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
374                     builder.setContentText(
375                             res.getString(R.string.notification_need_wifi_for_size));
376                     inboxStyle.setSummaryText(
377                             res.getString(R.string.notification_need_wifi_for_size));
378                 }
379 
380                 notif = inboxStyle.build();
381             }
382 
383             mNotifManager.notify(tag, 0, notif);
384         }
385 
386         // Remove stale tags that weren't renewed
387         for (int i = 0; i < mActiveNotifs.size();) {
388             final String tag = mActiveNotifs.keyAt(i);
389             if (clustered.containsKey(tag)) {
390                 i++;
391             } else {
392                 mNotifManager.cancel(tag, 0);
393                 mActiveNotifs.removeAt(i);
394             }
395         }
396     }
397 
getDownloadTitle(Resources res, Cursor cursor)398     private static CharSequence getDownloadTitle(Resources res, Cursor cursor) {
399         final String title = cursor.getString(UpdateQuery.TITLE);
400         if (!TextUtils.isEmpty(title)) {
401             return title;
402         } else {
403             return res.getString(R.string.download_unknown_title);
404         }
405     }
406 
getDownloadIds(Cursor cursor, IntArray cluster)407     private long[] getDownloadIds(Cursor cursor, IntArray cluster) {
408         final long[] ids = new long[cluster.size()];
409         for (int i = 0; i < cluster.size(); i++) {
410             cursor.moveToPosition(cluster.get(i));
411             ids[i] = cursor.getLong(UpdateQuery._ID);
412         }
413         return ids;
414     }
415 
dumpSpeeds()416     public void dumpSpeeds() {
417         synchronized (mDownloadSpeed) {
418             for (int i = 0; i < mDownloadSpeed.size(); i++) {
419                 final long id = mDownloadSpeed.keyAt(i);
420                 final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
421                 Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
422                         + delta + "ms ago");
423             }
424         }
425     }
426 
427     /**
428      * Build tag used for collapsing several downloads into a single
429      * {@link Notification}.
430      */
buildNotificationTag(Cursor cursor)431     private static String buildNotificationTag(Cursor cursor) {
432         final long id = cursor.getLong(UpdateQuery._ID);
433         final int status = cursor.getInt(UpdateQuery.STATUS);
434         final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
435         final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);
436 
437         if (isQueuedAndVisible(status, visibility)) {
438             return TYPE_WAITING + ":" + notifPackage;
439         } else if (isActiveAndVisible(status, visibility)) {
440             return TYPE_ACTIVE + ":" + notifPackage;
441         } else if (isCompleteAndVisible(status, visibility)) {
442             // Complete downloads always have unique notifs
443             return TYPE_COMPLETE + ":" + id;
444         } else {
445             return null;
446         }
447     }
448 
449     /**
450      * Return the cluster type of the given tag, as created by
451      * {@link #buildNotificationTag(Cursor)}.
452      */
getNotificationTagType(String tag)453     private static int getNotificationTagType(String tag) {
454         return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
455     }
456 
isQueuedAndVisible(int status, int visibility)457     private static boolean isQueuedAndVisible(int status, int visibility) {
458         return status == STATUS_QUEUED_FOR_WIFI &&
459                 (visibility == VISIBILITY_VISIBLE
460                 || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
461     }
462 
isActiveAndVisible(int status, int visibility)463     private static boolean isActiveAndVisible(int status, int visibility) {
464         return status == STATUS_RUNNING &&
465                 (visibility == VISIBILITY_VISIBLE
466                 || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
467     }
468 
isCompleteAndVisible(int status, int visibility)469     private static boolean isCompleteAndVisible(int status, int visibility) {
470         return Downloads.Impl.isStatusCompleted(status) &&
471                 (visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
472                 || visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
473     }
474 }
475