Show remaining time in download notifications.

Calculate speed of in-progress downloads and estimate time remaining
until completion.  Uses a moving average that is weighted 1:1 with
the most recent 500ms sample.  Funnels timing data to notifications
through DownloadHandler.

Bug: 6777872
Change-Id: I9155f2979aa330bd1172f63bbfca1d053815cee5
diff --git a/res/values/strings.xml b/res/values/strings.xml
index c607e35..3a060e2 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -205,6 +205,9 @@
         <item quantity="other"><xliff:g id="number">%d</xliff:g> files waiting</item>
     </plurals>
 
+    <!-- Time remaining until download is complete. [CHAR LIMIT=32] -->
+    <string name="download_remaining"><xliff:g id="duration" example="3 minutes">%s</xliff:g> left</string>
+
     <!-- Text for a toast appearing when a user clicks on a completed download, informing the user
          that there is no application on the device that can open the file that was downloaded
          [CHAR LIMIT=200] -->
diff --git a/src/com/android/providers/downloads/DownloadHandler.java b/src/com/android/providers/downloads/DownloadHandler.java
index 29d3470..dff09eb 100644
--- a/src/com/android/providers/downloads/DownloadHandler.java
+++ b/src/com/android/providers/downloads/DownloadHandler.java
@@ -18,6 +18,9 @@
 
 import android.content.res.Resources;
 import android.util.Log;
+import android.util.LongSparseArray;
+
+import com.android.internal.annotations.GuardedBy;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -25,31 +28,37 @@
 import java.util.LinkedHashMap;
 
 public class DownloadHandler {
-
     private static final String TAG = "DownloadHandler";
+
+    @GuardedBy("this")
     private final LinkedHashMap<Long, DownloadInfo> mDownloadsQueue =
             new LinkedHashMap<Long, DownloadInfo>();
+    @GuardedBy("this")
     private final HashMap<Long, DownloadInfo> mDownloadsInProgress =
             new HashMap<Long, DownloadInfo>();
-    private static final DownloadHandler mDownloadHandler = new DownloadHandler();
+    @GuardedBy("this")
+    private final LongSparseArray<Long> mRemainingMillis = new LongSparseArray<Long>();
+
     private final int mMaxConcurrentDownloadsAllowed = Resources.getSystem().getInteger(
             com.android.internal.R.integer.config_MaxConcurrentDownloadsAllowed);
 
-    static DownloadHandler getInstance() {
-        return mDownloadHandler;
+    private static final DownloadHandler sDownloadHandler = new DownloadHandler();
+
+    public static DownloadHandler getInstance() {
+        return sDownloadHandler;
     }
 
-    synchronized void enqueueDownload(DownloadInfo info) {
+    public synchronized void enqueueDownload(DownloadInfo info) {
         if (!mDownloadsQueue.containsKey(info.mId)) {
             if (Constants.LOGV) {
                 Log.i(TAG, "enqueued download. id: " + info.mId + ", uri: " + info.mUri);
             }
             mDownloadsQueue.put(info.mId, info);
-            startDownloadThread();
+            startDownloadThreadLocked();
         }
     }
 
-    private synchronized void startDownloadThread() {
+    private void startDownloadThreadLocked() {
         Iterator<Long> keys = mDownloadsQueue.keySet().iterator();
         ArrayList<Long> ids = new ArrayList<Long>();
         while (mDownloadsInProgress.size() < mMaxConcurrentDownloadsAllowed && keys.hasNext()) {
@@ -67,21 +76,34 @@
         }
     }
 
-    synchronized boolean hasDownloadInQueue(long id) {
+    public synchronized boolean hasDownloadInQueue(long id) {
         return mDownloadsQueue.containsKey(id) || mDownloadsInProgress.containsKey(id);
     }
 
-    synchronized void dequeueDownload(long mId) {
-        mDownloadsInProgress.remove(mId);
-        startDownloadThread();
+    public synchronized void dequeueDownload(long id) {
+        mDownloadsInProgress.remove(id);
+        mRemainingMillis.remove(id);
+        startDownloadThreadLocked();
         if (mDownloadsInProgress.size() == 0 && mDownloadsQueue.size() == 0) {
             notifyAll();
         }
     }
 
+    public synchronized void setRemainingMillis(long id, long millis) {
+        mRemainingMillis.put(id, millis);
+    }
+
+    /**
+     * Return remaining time until given {@link DownloadInfo} finishes, in
+     * milliseconds, or -1 if unknown.
+     */
+    public synchronized long getRemainingMillis(long id) {
+        return mRemainingMillis.get(id, -1L);
+    }
+
     // right now this is only used by tests. but there is no reason why it can't be used
     // by any module using DownloadManager (TODO add API to DownloadManager.java)
-    public synchronized void WaitUntilDownloadsTerminate() throws InterruptedException {
+    public synchronized void waitUntilDownloadsTerminate() throws InterruptedException {
         if (mDownloadsInProgress.size() == 0 && mDownloadsQueue.size() == 0) {
             if (Constants.LOGVV) {
                 Log.i(TAG, "nothing to wait on");
diff --git a/src/com/android/providers/downloads/DownloadNotifier.java b/src/com/android/providers/downloads/DownloadNotifier.java
index a1805e5..f6e7a2e 100644
--- a/src/com/android/providers/downloads/DownloadNotifier.java
+++ b/src/com/android/providers/downloads/DownloadNotifier.java
@@ -31,16 +31,15 @@
 import android.net.Uri;
 import android.provider.Downloads;
 import android.text.TextUtils;
+import android.text.format.DateUtils;
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
-import com.google.common.collect.Sets;
 
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.Set;
 
 import javax.annotation.concurrent.GuardedBy;
 
@@ -160,18 +159,26 @@
             String remainingText = null;
             String percentText = null;
             if (type == TYPE_ACTIVE) {
+                final DownloadHandler handler = DownloadHandler.getInstance();
+
                 long current = 0;
                 long total = 0;
+                long remainingMillis = -1;
                 for (DownloadInfo info : cluster) {
                     if (info.mTotalBytes != -1) {
                         current += info.mCurrentBytes;
                         total += info.mTotalBytes;
+                        remainingMillis = Math.max(
+                                handler.getRemainingMillis(info.mId), remainingMillis);
                     }
                 }
 
                 if (total > 0) {
                     final int percent = (int) ((current * 100) / total);
-                    // TODO: calculate remaining time based on recent bandwidth
+                    if (remainingMillis != -1) {
+                        remainingText = res.getString(R.string.download_remaining,
+                                DateUtils.formatDuration(remainingMillis));
+                    }
                     percentText = res.getString(R.string.download_percent, percent);
 
                     builder.setProgress(100, percent, false);
diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java
index 0a16a7d..5b767a2 100644
--- a/src/com/android/providers/downloads/DownloadService.java
+++ b/src/com/android/providers/downloads/DownloadService.java
@@ -39,6 +39,7 @@
 import android.text.TextUtils;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.util.IndentingPrintWriter;
 import com.google.android.collect.Maps;
 import com.google.common.annotations.VisibleForTesting;
@@ -72,6 +73,7 @@
      * downloads based on this data, so that it can deal with situation where the data in the
      * content provider changes or disappears.
      */
+    @GuardedBy("mDownloads")
     private Map<Long, DownloadInfo> mDownloads = Maps.newHashMap();
 
     /**
diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java
index e74d5c7..2bd3d36 100644
--- a/src/com/android/providers/downloads/DownloadThread.java
+++ b/src/com/android/providers/downloads/DownloadThread.java
@@ -29,6 +29,7 @@
 import android.os.FileUtils;
 import android.os.PowerManager;
 import android.os.Process;
+import android.os.SystemClock;
 import android.provider.Downloads;
 import android.text.TextUtils;
 import android.util.Log;
@@ -100,6 +101,15 @@
         public long mBytesNotified = 0;
         public long mTimeLastNotification = 0;
 
+        /** Historical bytes/second speed of this download. */
+        public long mSpeed;
+        /** Time when current sample started. */
+        public long mSpeedSampleStart;
+        /** Bytes transferred since current sample started. */
+        public long mSpeedSampleBytes;
+        /** Estimated time until finished. */
+        public long mRemainingMillis;
+
         public State(DownloadInfo info) {
             mMimeType = Intent.normalizeMimeType(info.mMimeType);
             mRequestUri = info.mUri;
@@ -423,7 +433,32 @@
      * Report download progress through the database if necessary.
      */
     private void reportProgress(State state, InnerState innerState) {
-        long now = mSystemFacade.currentTimeMillis();
+        final long now = SystemClock.elapsedRealtime();
+
+        final long sampleDelta = now - state.mSpeedSampleStart;
+        if (sampleDelta > 500) {
+            final long sampleSpeed = ((state.mCurrentBytes - state.mSpeedSampleBytes) * 1000)
+                    / sampleDelta;
+
+            if (state.mSpeed == 0) {
+                state.mSpeed = sampleSpeed;
+            } else {
+                state.mSpeed = (state.mSpeed + sampleSpeed) / 2;
+            }
+
+            state.mSpeedSampleStart = now;
+            state.mSpeedSampleBytes = state.mCurrentBytes;
+
+            if (state.mSpeed != 0) {
+                state.mRemainingMillis = ((state.mTotalBytes - state.mCurrentBytes) * 1000)
+                        / state.mSpeed;
+            } else {
+                state.mRemainingMillis = -1;
+            }
+
+            DownloadHandler.getInstance().setRemainingMillis(mInfo.mId, state.mRemainingMillis);
+        }
+
         if (state.mCurrentBytes - state.mBytesNotified > Constants.MIN_PROGRESS_STEP &&
             now - state.mTimeLastNotification > Constants.MIN_PROGRESS_TIME) {
             ContentValues values = new ContentValues();
diff --git a/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java b/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java
index bbc5c3e..23d300f 100644
--- a/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java
+++ b/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java
@@ -116,7 +116,7 @@
             int rslt = getDownloadStatus(downloadUri);
             if (rslt == Downloads.Impl.STATUS_RUNNING || rslt == Downloads.Impl.STATUS_PENDING) {
                 Log.i(TAG, "status is: " + rslt + ", for: " + downloadUri);
-                DownloadHandler.getInstance().WaitUntilDownloadsTerminate();
+                DownloadHandler.getInstance().waitUntilDownloadsTerminate();
                 Thread.sleep(100);
             } else {
                 done = true;