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