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