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