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