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