Home | History | Annotate | Download | only in downloads
      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 
     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 
    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      */
    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 
    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 
    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 
    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 
    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 
    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      */
    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      */
    453     private static int getNotificationTagType(String tag) {
    454         return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
    455     }
    456 
    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 
    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 
    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