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