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