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 remainingText = null;
291             String percentText = null;
292             if (type == TYPE_ACTIVE) {
293                 long current = 0;
294                 long total = 0;
295                 long speed = 0;
296                 synchronized (mDownloadSpeed) {
297                     for (int j = 0; j < cluster.size(); j++) {
298                         cursor.moveToPosition(cluster.get(j));
299 
300                         final long id = cursor.getLong(UpdateQuery._ID);
301                         final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
302                         final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);
303 
304                         if (totalBytes != -1) {
305                             current += currentBytes;
306                             total += totalBytes;
307                             speed += mDownloadSpeed.get(id);
308                         }
309                     }
310                 }
311 
312                 if (total > 0) {
313                     percentText =
314                             NumberFormat.getPercentInstance().format((double) current / total);
315 
316                     if (speed > 0) {
317                         final long remainingMillis = ((total - current) * 1000) / speed;
318                         remainingText = res.getString(R.string.download_remaining,
319                                 DateUtils.formatDuration(remainingMillis));
320                     }
321 
322                     final int percent = (int) ((current * 100) / total);
323                     builder.setProgress(100, percent, false);
324                 } else {
325                     builder.setProgress(100, 0, true);
326                 }
327             }
328 
329             // Build titles and description
330             final Notification notif;
331             if (cluster.size() == 1) {
332                 cursor.moveToPosition(cluster.get(0));
333                 builder.setContentTitle(getDownloadTitle(res, cursor));
334 
335                 if (type == TYPE_ACTIVE) {
336                     final String description = cursor.getString(UpdateQuery.DESCRIPTION);
337                     if (!TextUtils.isEmpty(description)) {
338                         builder.setContentText(description);
339                     } else {
340                         builder.setContentText(remainingText);
341                     }
342                     builder.setContentInfo(percentText);
343 
344                 } else if (type == TYPE_WAITING) {
345                     builder.setContentText(
346                             res.getString(R.string.notification_need_wifi_for_size));
347 
348                 } else if (type == TYPE_COMPLETE) {
349                     final int status = cursor.getInt(UpdateQuery.STATUS);
350                     if (Downloads.Impl.isStatusError(status)) {
351                         builder.setContentText(res.getText(R.string.notification_download_failed));
352                     } else if (Downloads.Impl.isStatusSuccess(status)) {
353                         builder.setContentText(
354                                 res.getText(R.string.notification_download_complete));
355                     }
356                 }
357 
358                 notif = builder.build();
359 
360             } else {
361                 final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
362 
363                 for (int j = 0; j < cluster.size(); j++) {
364                     cursor.moveToPosition(cluster.get(j));
365                     inboxStyle.addLine(getDownloadTitle(res, cursor));
366                 }
367 
368                 if (type == TYPE_ACTIVE) {
369                     builder.setContentTitle(res.getQuantityString(
370                             R.plurals.notif_summary_active, cluster.size(), cluster.size()));
371                     builder.setContentText(remainingText);
372                     builder.setContentInfo(percentText);
373                     inboxStyle.setSummaryText(remainingText);
374 
375                 } else if (type == TYPE_WAITING) {
376                     builder.setContentTitle(res.getQuantityString(
377                             R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
378                     builder.setContentText(
379                             res.getString(R.string.notification_need_wifi_for_size));
380                     inboxStyle.setSummaryText(
381                             res.getString(R.string.notification_need_wifi_for_size));
382                 }
383 
384                 notif = inboxStyle.build();
385             }
386 
387             mNotifManager.notify(tag, 0, notif);
388         }
389 
390         // Remove stale tags that weren't renewed
391         for (int i = 0; i < mActiveNotifs.size();) {
392             final String tag = mActiveNotifs.keyAt(i);
393             if (clustered.containsKey(tag)) {
394                 i++;
395             } else {
396                 mNotifManager.cancel(tag, 0);
397                 mActiveNotifs.removeAt(i);
398             }
399         }
400     }
401 
getDownloadTitle(Resources res, Cursor cursor)402     private static CharSequence getDownloadTitle(Resources res, Cursor cursor) {
403         final String title = cursor.getString(UpdateQuery.TITLE);
404         if (!TextUtils.isEmpty(title)) {
405             return title;
406         } else {
407             return res.getString(R.string.download_unknown_title);
408         }
409     }
410 
getDownloadIds(Cursor cursor, IntArray cluster)411     private long[] getDownloadIds(Cursor cursor, IntArray cluster) {
412         final long[] ids = new long[cluster.size()];
413         for (int i = 0; i < cluster.size(); i++) {
414             cursor.moveToPosition(cluster.get(i));
415             ids[i] = cursor.getLong(UpdateQuery._ID);
416         }
417         return ids;
418     }
419 
dumpSpeeds()420     public void dumpSpeeds() {
421         synchronized (mDownloadSpeed) {
422             for (int i = 0; i < mDownloadSpeed.size(); i++) {
423                 final long id = mDownloadSpeed.keyAt(i);
424                 final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
425                 Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
426                         + delta + "ms ago");
427             }
428         }
429     }
430 
431     /**
432      * Build tag used for collapsing several downloads into a single
433      * {@link Notification}.
434      */
buildNotificationTag(Cursor cursor)435     private static String buildNotificationTag(Cursor cursor) {
436         final long id = cursor.getLong(UpdateQuery._ID);
437         final int status = cursor.getInt(UpdateQuery.STATUS);
438         final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
439         final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);
440 
441         if (isQueuedAndVisible(status, visibility)) {
442             return TYPE_WAITING + ":" + notifPackage;
443         } else if (isActiveAndVisible(status, visibility)) {
444             return TYPE_ACTIVE + ":" + notifPackage;
445         } else if (isCompleteAndVisible(status, visibility)) {
446             // Complete downloads always have unique notifs
447             return TYPE_COMPLETE + ":" + id;
448         } else {
449             return null;
450         }
451     }
452 
453     /**
454      * Return the cluster type of the given tag, as created by
455      * {@link #buildNotificationTag(Cursor)}.
456      */
getNotificationTagType(String tag)457     private static int getNotificationTagType(String tag) {
458         return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
459     }
460 
isQueuedAndVisible(int status, int visibility)461     private static boolean isQueuedAndVisible(int status, int visibility) {
462         return status == STATUS_QUEUED_FOR_WIFI &&
463                 (visibility == VISIBILITY_VISIBLE
464                 || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
465     }
466 
isActiveAndVisible(int status, int visibility)467     private static boolean isActiveAndVisible(int status, int visibility) {
468         return status == STATUS_RUNNING &&
469                 (visibility == VISIBILITY_VISIBLE
470                 || visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
471     }
472 
isCompleteAndVisible(int status, int visibility)473     private static boolean isCompleteAndVisible(int status, int visibility) {
474         return Downloads.Impl.isStatusCompleted(status) &&
475                 (visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
476                 || visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
477     }
478 }
479