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