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