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_RUNNING; 23import static com.android.providers.downloads.Constants.TAG; 24 25import android.app.DownloadManager; 26import android.app.Notification; 27import android.app.NotificationManager; 28import android.app.PendingIntent; 29import android.content.ContentUris; 30import android.content.Context; 31import android.content.Intent; 32import android.content.res.Resources; 33import android.net.Uri; 34import android.os.SystemClock; 35import android.provider.Downloads; 36import android.text.TextUtils; 37import android.text.format.DateUtils; 38import android.util.Log; 39import android.util.LongSparseLongArray; 40 41import com.google.common.collect.ArrayListMultimap; 42import com.google.common.collect.Maps; 43import com.google.common.collect.Multimap; 44 45import java.text.NumberFormat; 46import java.util.Collection; 47import java.util.HashMap; 48import java.util.Iterator; 49 50import javax.annotation.concurrent.GuardedBy; 51 52/** 53 * Update {@link NotificationManager} to reflect current {@link DownloadInfo} 54 * states. Collapses similar downloads into a single notification, and builds 55 * {@link PendingIntent} that launch towards {@link DownloadReceiver}. 56 */ 57public class DownloadNotifier { 58 59 private static final int TYPE_ACTIVE = 1; 60 private static final int TYPE_WAITING = 2; 61 private static final int TYPE_COMPLETE = 3; 62 63 private final Context mContext; 64 private final NotificationManager mNotifManager; 65 66 /** 67 * Currently active notifications, mapped from clustering tag to timestamp 68 * when first shown. 69 * 70 * @see #buildNotificationTag(DownloadInfo) 71 */ 72 @GuardedBy("mActiveNotifs") 73 private final HashMap<String, Long> mActiveNotifs = Maps.newHashMap(); 74 75 /** 76 * Current speed of active downloads, mapped from {@link DownloadInfo#mId} 77 * to speed in bytes per second. 78 */ 79 @GuardedBy("mDownloadSpeed") 80 private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray(); 81 82 /** 83 * Last time speed was reproted, mapped from {@link DownloadInfo#mId} to 84 * {@link SystemClock#elapsedRealtime()}. 85 */ 86 @GuardedBy("mDownloadSpeed") 87 private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray(); 88 89 public DownloadNotifier(Context context) { 90 mContext = context; 91 mNotifManager = (NotificationManager) context.getSystemService( 92 Context.NOTIFICATION_SERVICE); 93 } 94 95 public void cancelAll() { 96 mNotifManager.cancelAll(); 97 } 98 99 /** 100 * Notify the current speed of an active download, used for calculating 101 * estimated remaining time. 102 */ 103 public void notifyDownloadSpeed(long id, long bytesPerSecond) { 104 synchronized (mDownloadSpeed) { 105 if (bytesPerSecond != 0) { 106 mDownloadSpeed.put(id, bytesPerSecond); 107 mDownloadTouch.put(id, SystemClock.elapsedRealtime()); 108 } else { 109 mDownloadSpeed.delete(id); 110 mDownloadTouch.delete(id); 111 } 112 } 113 } 114 115 /** 116 * Update {@link NotificationManager} to reflect the given set of 117 * {@link DownloadInfo}, adding, collapsing, and removing as needed. 118 */ 119 public void updateWith(Collection<DownloadInfo> downloads) { 120 synchronized (mActiveNotifs) { 121 updateWithLocked(downloads); 122 } 123 } 124 125 private void updateWithLocked(Collection<DownloadInfo> downloads) { 126 final Resources res = mContext.getResources(); 127 128 // Cluster downloads together 129 final Multimap<String, DownloadInfo> clustered = ArrayListMultimap.create(); 130 for (DownloadInfo info : downloads) { 131 final String tag = buildNotificationTag(info); 132 if (tag != null) { 133 clustered.put(tag, info); 134 } 135 } 136 137 // Build notification for each cluster 138 for (String tag : clustered.keySet()) { 139 final int type = getNotificationTagType(tag); 140 final Collection<DownloadInfo> cluster = clustered.get(tag); 141 142 final Notification.Builder builder = new Notification.Builder(mContext); 143 builder.setColor(res.getColor( 144 com.android.internal.R.color.system_notification_accent_color)); 145 146 // Use time when cluster was first shown to avoid shuffling 147 final long firstShown; 148 if (mActiveNotifs.containsKey(tag)) { 149 firstShown = mActiveNotifs.get(tag); 150 } else { 151 firstShown = System.currentTimeMillis(); 152 mActiveNotifs.put(tag, firstShown); 153 } 154 builder.setWhen(firstShown); 155 156 // Show relevant icon 157 if (type == TYPE_ACTIVE) { 158 builder.setSmallIcon(android.R.drawable.stat_sys_download); 159 } else if (type == TYPE_WAITING) { 160 builder.setSmallIcon(android.R.drawable.stat_sys_warning); 161 } else if (type == TYPE_COMPLETE) { 162 builder.setSmallIcon(android.R.drawable.stat_sys_download_done); 163 } 164 165 // Build action intents 166 if (type == TYPE_ACTIVE || type == TYPE_WAITING) { 167 // build a synthetic uri for intent identification purposes 168 final Uri uri = new Uri.Builder().scheme("active-dl").appendPath(tag).build(); 169 final Intent intent = new Intent(Constants.ACTION_LIST, 170 uri, mContext, DownloadReceiver.class); 171 intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, 172 getDownloadIds(cluster)); 173 builder.setContentIntent(PendingIntent.getBroadcast(mContext, 174 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)); 175 builder.setOngoing(true); 176 177 } else if (type == TYPE_COMPLETE) { 178 final DownloadInfo info = cluster.iterator().next(); 179 final Uri uri = ContentUris.withAppendedId( 180 Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, info.mId); 181 builder.setAutoCancel(true); 182 183 final String action; 184 if (Downloads.Impl.isStatusError(info.mStatus)) { 185 action = Constants.ACTION_LIST; 186 } else { 187 if (info.mDestination != Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) { 188 action = Constants.ACTION_OPEN; 189 } else { 190 action = Constants.ACTION_LIST; 191 } 192 } 193 194 final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class); 195 intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, 196 getDownloadIds(cluster)); 197 builder.setContentIntent(PendingIntent.getBroadcast(mContext, 198 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)); 199 200 final Intent hideIntent = new Intent(Constants.ACTION_HIDE, 201 uri, mContext, DownloadReceiver.class); 202 builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent, 0)); 203 } 204 205 // Calculate and show progress 206 String remainingText = null; 207 String percentText = null; 208 if (type == TYPE_ACTIVE) { 209 long current = 0; 210 long total = 0; 211 long speed = 0; 212 synchronized (mDownloadSpeed) { 213 for (DownloadInfo info : cluster) { 214 if (info.mTotalBytes != -1) { 215 current += info.mCurrentBytes; 216 total += info.mTotalBytes; 217 speed += mDownloadSpeed.get(info.mId); 218 } 219 } 220 } 221 222 if (total > 0) { 223 percentText = 224 NumberFormat.getPercentInstance().format((double) current / total); 225 226 if (speed > 0) { 227 final long remainingMillis = ((total - current) * 1000) / speed; 228 remainingText = res.getString(R.string.download_remaining, 229 DateUtils.formatDuration(remainingMillis)); 230 } 231 232 final int percent = (int) ((current * 100) / total); 233 builder.setProgress(100, percent, false); 234 } else { 235 builder.setProgress(100, 0, true); 236 } 237 } 238 239 // Build titles and description 240 final Notification notif; 241 if (cluster.size() == 1) { 242 final DownloadInfo info = cluster.iterator().next(); 243 244 builder.setContentTitle(getDownloadTitle(res, info)); 245 246 if (type == TYPE_ACTIVE) { 247 if (!TextUtils.isEmpty(info.mDescription)) { 248 builder.setContentText(info.mDescription); 249 } else { 250 builder.setContentText(remainingText); 251 } 252 builder.setContentInfo(percentText); 253 254 } else if (type == TYPE_WAITING) { 255 builder.setContentText( 256 res.getString(R.string.notification_need_wifi_for_size)); 257 258 } else if (type == TYPE_COMPLETE) { 259 if (Downloads.Impl.isStatusError(info.mStatus)) { 260 builder.setContentText(res.getText(R.string.notification_download_failed)); 261 } else if (Downloads.Impl.isStatusSuccess(info.mStatus)) { 262 builder.setContentText( 263 res.getText(R.string.notification_download_complete)); 264 } 265 } 266 267 notif = builder.build(); 268 269 } else { 270 final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder); 271 272 for (DownloadInfo info : cluster) { 273 inboxStyle.addLine(getDownloadTitle(res, info)); 274 } 275 276 if (type == TYPE_ACTIVE) { 277 builder.setContentTitle(res.getQuantityString( 278 R.plurals.notif_summary_active, cluster.size(), cluster.size())); 279 builder.setContentText(remainingText); 280 builder.setContentInfo(percentText); 281 inboxStyle.setSummaryText(remainingText); 282 283 } else if (type == TYPE_WAITING) { 284 builder.setContentTitle(res.getQuantityString( 285 R.plurals.notif_summary_waiting, cluster.size(), cluster.size())); 286 builder.setContentText( 287 res.getString(R.string.notification_need_wifi_for_size)); 288 inboxStyle.setSummaryText( 289 res.getString(R.string.notification_need_wifi_for_size)); 290 } 291 292 notif = inboxStyle.build(); 293 } 294 295 mNotifManager.notify(tag, 0, notif); 296 } 297 298 // Remove stale tags that weren't renewed 299 final Iterator<String> it = mActiveNotifs.keySet().iterator(); 300 while (it.hasNext()) { 301 final String tag = it.next(); 302 if (!clustered.containsKey(tag)) { 303 mNotifManager.cancel(tag, 0); 304 it.remove(); 305 } 306 } 307 } 308 309 private static CharSequence getDownloadTitle(Resources res, DownloadInfo info) { 310 if (!TextUtils.isEmpty(info.mTitle)) { 311 return info.mTitle; 312 } else { 313 return res.getString(R.string.download_unknown_title); 314 } 315 } 316 317 private long[] getDownloadIds(Collection<DownloadInfo> infos) { 318 final long[] ids = new long[infos.size()]; 319 int i = 0; 320 for (DownloadInfo info : infos) { 321 ids[i++] = info.mId; 322 } 323 return ids; 324 } 325 326 public void dumpSpeeds() { 327 synchronized (mDownloadSpeed) { 328 for (int i = 0; i < mDownloadSpeed.size(); i++) { 329 final long id = mDownloadSpeed.keyAt(i); 330 final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id); 331 Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, " 332 + delta + "ms ago"); 333 } 334 } 335 } 336 337 /** 338 * Build tag used for collapsing several {@link DownloadInfo} into a single 339 * {@link Notification}. 340 */ 341 private static String buildNotificationTag(DownloadInfo info) { 342 if (info.mStatus == Downloads.Impl.STATUS_QUEUED_FOR_WIFI) { 343 return TYPE_WAITING + ":" + info.mPackage; 344 } else if (isActiveAndVisible(info)) { 345 return TYPE_ACTIVE + ":" + info.mPackage; 346 } else if (isCompleteAndVisible(info)) { 347 // Complete downloads always have unique notifs 348 return TYPE_COMPLETE + ":" + info.mId; 349 } else { 350 return null; 351 } 352 } 353 354 /** 355 * Return the cluster type of the given tag, as created by 356 * {@link #buildNotificationTag(DownloadInfo)}. 357 */ 358 private static int getNotificationTagType(String tag) { 359 return Integer.parseInt(tag.substring(0, tag.indexOf(':'))); 360 } 361 362 private static boolean isActiveAndVisible(DownloadInfo download) { 363 return download.mStatus == STATUS_RUNNING && 364 (download.mVisibility == VISIBILITY_VISIBLE 365 || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED); 366 } 367 368 private static boolean isCompleteAndVisible(DownloadInfo download) { 369 return Downloads.Impl.isStatusCompleted(download.mStatus) && 370 (download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED 371 || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION); 372 } 373} 374