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