blob: 224aee4f2b183d44d42847d7df8f9f5b23bd231d [file] [log] [blame]
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.downloads;
import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE;
import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED;
import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION;
import static android.provider.Downloads.Impl.STATUS_QUEUED_FOR_WIFI;
import static android.provider.Downloads.Impl.STATUS_RUNNING;
import static com.android.providers.downloads.Constants.TAG;
import android.app.DownloadManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.os.SystemClock;
import android.provider.Downloads;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.IntArray;
import android.util.Log;
import android.util.LongSparseLongArray;
import com.android.internal.util.ArrayUtils;
import java.text.NumberFormat;
import javax.annotation.concurrent.GuardedBy;
/**
* Update {@link NotificationManager} to reflect current download states.
* Collapses similar downloads into a single notification, and builds
* {@link PendingIntent} that launch towards {@link DownloadReceiver}.
*/
public class DownloadNotifier {
private static final int TYPE_ACTIVE = 1;
private static final int TYPE_WAITING = 2;
private static final int TYPE_COMPLETE = 3;
private static final String CHANNEL_ACTIVE = "active";
private static final String CHANNEL_WAITING = "waiting";
private static final String CHANNEL_COMPLETE = "complete";
private final Context mContext;
private final NotificationManager mNotifManager;
/**
* Currently active notifications, mapped from clustering tag to timestamp
* when first shown.
*
* @see #buildNotificationTag(Cursor)
*/
@GuardedBy("mActiveNotifs")
private final ArrayMap<String, Long> mActiveNotifs = new ArrayMap<>();
/**
* Current speed of active downloads, mapped from download ID to speed in
* bytes per second.
*/
@GuardedBy("mDownloadSpeed")
private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray();
/**
* Last time speed was reproted, mapped from download ID to
* {@link SystemClock#elapsedRealtime()}.
*/
@GuardedBy("mDownloadSpeed")
private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray();
public DownloadNotifier(Context context) {
mContext = context;
mNotifManager = context.getSystemService(NotificationManager.class);
// Ensure that all our channels are ready to use
mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_ACTIVE,
context.getText(R.string.download_running),
NotificationManager.IMPORTANCE_MIN));
mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_WAITING,
context.getText(R.string.download_queued),
NotificationManager.IMPORTANCE_DEFAULT));
mNotifManager.createNotificationChannel(new NotificationChannel(CHANNEL_COMPLETE,
context.getText(com.android.internal.R.string.done_label),
NotificationManager.IMPORTANCE_DEFAULT));
}
public void init() {
synchronized (mActiveNotifs) {
mActiveNotifs.clear();
final StatusBarNotification[] notifs = mNotifManager.getActiveNotifications();
if (!ArrayUtils.isEmpty(notifs)) {
for (StatusBarNotification notif : notifs) {
mActiveNotifs.put(notif.getTag(), notif.getPostTime());
}
}
}
}
/**
* Notify the current speed of an active download, used for calculating
* estimated remaining time.
*/
public void notifyDownloadSpeed(long id, long bytesPerSecond) {
synchronized (mDownloadSpeed) {
if (bytesPerSecond != 0) {
mDownloadSpeed.put(id, bytesPerSecond);
mDownloadTouch.put(id, SystemClock.elapsedRealtime());
} else {
mDownloadSpeed.delete(id);
mDownloadTouch.delete(id);
}
}
}
private interface UpdateQuery {
final String[] PROJECTION = new String[] {
Downloads.Impl._ID,
Downloads.Impl.COLUMN_STATUS,
Downloads.Impl.COLUMN_VISIBILITY,
Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE,
Downloads.Impl.COLUMN_CURRENT_BYTES,
Downloads.Impl.COLUMN_TOTAL_BYTES,
Downloads.Impl.COLUMN_DESTINATION,
Downloads.Impl.COLUMN_TITLE,
Downloads.Impl.COLUMN_DESCRIPTION,
};
final int _ID = 0;
final int STATUS = 1;
final int VISIBILITY = 2;
final int NOTIFICATION_PACKAGE = 3;
final int CURRENT_BYTES = 4;
final int TOTAL_BYTES = 5;
final int DESTINATION = 6;
final int TITLE = 7;
final int DESCRIPTION = 8;
}
public void update() {
try (Cursor cursor = mContext.getContentResolver().query(
Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, UpdateQuery.PROJECTION,
Downloads.Impl.COLUMN_DELETED + " == '0'", null, null)) {
synchronized (mActiveNotifs) {
updateWithLocked(cursor);
}
}
}
private void updateWithLocked(Cursor cursor) {
final Resources res = mContext.getResources();
// Cluster downloads together
final ArrayMap<String, IntArray> clustered = new ArrayMap<>();
while (cursor.moveToNext()) {
final String tag = buildNotificationTag(cursor);
if (tag != null) {
IntArray cluster = clustered.get(tag);
if (cluster == null) {
cluster = new IntArray();
clustered.put(tag, cluster);
}
cluster.add(cursor.getPosition());
}
}
// Build notification for each cluster
for (int i = 0; i < clustered.size(); i++) {
final String tag = clustered.keyAt(i);
final IntArray cluster = clustered.valueAt(i);
final int type = getNotificationTagType(tag);
final Notification.Builder builder;
if (type == TYPE_ACTIVE) {
builder = new Notification.Builder(mContext, CHANNEL_ACTIVE);
builder.setSmallIcon(android.R.drawable.stat_sys_download);
} else if (type == TYPE_WAITING) {
builder = new Notification.Builder(mContext, CHANNEL_WAITING);
builder.setSmallIcon(android.R.drawable.stat_sys_warning);
} else if (type == TYPE_COMPLETE) {
builder = new Notification.Builder(mContext, CHANNEL_COMPLETE);
builder.setSmallIcon(android.R.drawable.stat_sys_download_done);
} else {
continue;
}
builder.setColor(res.getColor(
com.android.internal.R.color.system_notification_accent_color));
// Use time when cluster was first shown to avoid shuffling
final long firstShown;
if (mActiveNotifs.containsKey(tag)) {
firstShown = mActiveNotifs.get(tag);
} else {
firstShown = System.currentTimeMillis();
mActiveNotifs.put(tag, firstShown);
}
builder.setWhen(firstShown);
builder.setOnlyAlertOnce(true);
// Build action intents
if (type == TYPE_ACTIVE || type == TYPE_WAITING) {
final long[] downloadIds = getDownloadIds(cursor, cluster);
// build a synthetic uri for intent identification purposes
final Uri uri = new Uri.Builder().scheme("active-dl").appendPath(tag).build();
final Intent intent = new Intent(Constants.ACTION_LIST,
uri, mContext, DownloadReceiver.class);
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
downloadIds);
builder.setContentIntent(PendingIntent.getBroadcast(mContext,
0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
if (type == TYPE_ACTIVE) {
builder.setOngoing(true);
}
// Add a Cancel action
final Uri cancelUri = new Uri.Builder().scheme("cancel-dl").appendPath(tag).build();
final Intent cancelIntent = new Intent(Constants.ACTION_CANCEL,
cancelUri, mContext, DownloadReceiver.class);
cancelIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_IDS, downloadIds);
cancelIntent.putExtra(DownloadReceiver.EXTRA_CANCELED_DOWNLOAD_NOTIFICATION_TAG, tag);
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
res.getString(R.string.button_cancel_download),
PendingIntent.getBroadcast(mContext,
0, cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
} else if (type == TYPE_COMPLETE) {
cursor.moveToPosition(cluster.get(0));
final long id = cursor.getLong(UpdateQuery._ID);
final int status = cursor.getInt(UpdateQuery.STATUS);
final int destination = cursor.getInt(UpdateQuery.DESTINATION);
final Uri uri = ContentUris.withAppendedId(
Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id);
builder.setAutoCancel(true);
final String action;
if (Downloads.Impl.isStatusError(status)) {
action = Constants.ACTION_LIST;
} else {
action = Constants.ACTION_OPEN;
}
final Intent intent = new Intent(action, uri, mContext, DownloadReceiver.class);
intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS,
getDownloadIds(cursor, cluster));
builder.setContentIntent(PendingIntent.getBroadcast(mContext,
0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
final Intent hideIntent = new Intent(Constants.ACTION_HIDE,
uri, mContext, DownloadReceiver.class);
hideIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
builder.setDeleteIntent(PendingIntent.getBroadcast(mContext, 0, hideIntent,
PendingIntent.FLAG_IMMUTABLE));
}
// Calculate and show progress
String remainingText = null;
String percentText = null;
if (type == TYPE_ACTIVE) {
long current = 0;
long total = 0;
long speed = 0;
synchronized (mDownloadSpeed) {
for (int j = 0; j < cluster.size(); j++) {
cursor.moveToPosition(cluster.get(j));
final long id = cursor.getLong(UpdateQuery._ID);
final long currentBytes = cursor.getLong(UpdateQuery.CURRENT_BYTES);
final long totalBytes = cursor.getLong(UpdateQuery.TOTAL_BYTES);
if (totalBytes != -1) {
current += currentBytes;
total += totalBytes;
speed += mDownloadSpeed.get(id);
}
}
}
if (total > 0) {
percentText =
NumberFormat.getPercentInstance().format((double) current / total);
if (speed > 0) {
final long remainingMillis = ((total - current) * 1000) / speed;
remainingText = res.getString(R.string.download_remaining,
DateUtils.formatDuration(remainingMillis));
}
final int percent = (int) ((current * 100) / total);
builder.setProgress(100, percent, false);
} else {
builder.setProgress(100, 0, true);
}
}
// Build titles and description
final Notification notif;
if (cluster.size() == 1) {
cursor.moveToPosition(cluster.get(0));
builder.setContentTitle(getDownloadTitle(res, cursor));
if (type == TYPE_ACTIVE) {
final String description = cursor.getString(UpdateQuery.DESCRIPTION);
if (!TextUtils.isEmpty(description)) {
builder.setContentText(description);
} else {
builder.setContentText(remainingText);
}
builder.setContentInfo(percentText);
} else if (type == TYPE_WAITING) {
builder.setContentText(
res.getString(R.string.notification_need_wifi_for_size));
} else if (type == TYPE_COMPLETE) {
final int status = cursor.getInt(UpdateQuery.STATUS);
if (Downloads.Impl.isStatusError(status)) {
builder.setContentText(res.getText(R.string.notification_download_failed));
} else if (Downloads.Impl.isStatusSuccess(status)) {
builder.setContentText(
res.getText(R.string.notification_download_complete));
}
}
notif = builder.build();
} else {
final Notification.InboxStyle inboxStyle = new Notification.InboxStyle(builder);
for (int j = 0; j < cluster.size(); j++) {
cursor.moveToPosition(cluster.get(j));
inboxStyle.addLine(getDownloadTitle(res, cursor));
}
if (type == TYPE_ACTIVE) {
builder.setContentTitle(res.getQuantityString(
R.plurals.notif_summary_active, cluster.size(), cluster.size()));
builder.setContentText(remainingText);
builder.setContentInfo(percentText);
inboxStyle.setSummaryText(remainingText);
} else if (type == TYPE_WAITING) {
builder.setContentTitle(res.getQuantityString(
R.plurals.notif_summary_waiting, cluster.size(), cluster.size()));
builder.setContentText(
res.getString(R.string.notification_need_wifi_for_size));
inboxStyle.setSummaryText(
res.getString(R.string.notification_need_wifi_for_size));
}
notif = inboxStyle.build();
}
mNotifManager.notify(tag, 0, notif);
}
// Remove stale tags that weren't renewed
for (int i = 0; i < mActiveNotifs.size();) {
final String tag = mActiveNotifs.keyAt(i);
if (clustered.containsKey(tag)) {
i++;
} else {
mNotifManager.cancel(tag, 0);
mActiveNotifs.removeAt(i);
}
}
}
private static CharSequence getDownloadTitle(Resources res, Cursor cursor) {
final String title = cursor.getString(UpdateQuery.TITLE);
if (!TextUtils.isEmpty(title)) {
return title;
} else {
return res.getString(R.string.download_unknown_title);
}
}
private long[] getDownloadIds(Cursor cursor, IntArray cluster) {
final long[] ids = new long[cluster.size()];
for (int i = 0; i < cluster.size(); i++) {
cursor.moveToPosition(cluster.get(i));
ids[i] = cursor.getLong(UpdateQuery._ID);
}
return ids;
}
public void dumpSpeeds() {
synchronized (mDownloadSpeed) {
for (int i = 0; i < mDownloadSpeed.size(); i++) {
final long id = mDownloadSpeed.keyAt(i);
final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id);
Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, "
+ delta + "ms ago");
}
}
}
/**
* Build tag used for collapsing several downloads into a single
* {@link Notification}.
*/
private static String buildNotificationTag(Cursor cursor) {
final long id = cursor.getLong(UpdateQuery._ID);
final int status = cursor.getInt(UpdateQuery.STATUS);
final int visibility = cursor.getInt(UpdateQuery.VISIBILITY);
final String notifPackage = cursor.getString(UpdateQuery.NOTIFICATION_PACKAGE);
if (isQueuedAndVisible(status, visibility)) {
return TYPE_WAITING + ":" + notifPackage;
} else if (isActiveAndVisible(status, visibility)) {
return TYPE_ACTIVE + ":" + notifPackage;
} else if (isCompleteAndVisible(status, visibility)) {
// Complete downloads always have unique notifs
return TYPE_COMPLETE + ":" + id;
} else {
return null;
}
}
/**
* Return the cluster type of the given tag, as created by
* {@link #buildNotificationTag(Cursor)}.
*/
private static int getNotificationTagType(String tag) {
return Integer.parseInt(tag.substring(0, tag.indexOf(':')));
}
private static boolean isQueuedAndVisible(int status, int visibility) {
return status == STATUS_QUEUED_FOR_WIFI &&
(visibility == VISIBILITY_VISIBLE
|| visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
}
private static boolean isActiveAndVisible(int status, int visibility) {
return status == STATUS_RUNNING &&
(visibility == VISIBILITY_VISIBLE
|| visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
}
private static boolean isCompleteAndVisible(int status, int visibility) {
return Downloads.Impl.isStatusCompleted(status) &&
(visibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED
|| visibility == VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION);
}
}