summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/com/android/providers/downloads/DownloadInfo.java50
-rw-r--r--src/com/android/providers/downloads/DownloadScanner.java157
-rw-r--r--src/com/android/providers/downloads/DownloadService.java539
-rw-r--r--src/com/android/providers/downloads/DownloadThread.java26
-rw-r--r--tests/Android.mk2
-rw-r--r--tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java4
-rw-r--r--tests/src/com/android/providers/downloads/MockitoHelper.java53
7 files changed, 459 insertions, 372 deletions
diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java
index 74b52d48..65242227 100644
--- a/src/com/android/providers/downloads/DownloadInfo.java
+++ b/src/com/android/providers/downloads/DownloadInfo.java
@@ -244,10 +244,10 @@ public class DownloadInfo {
/**
* Result of last {@link DownloadThread} started by
- * {@link #startIfReady(ExecutorService)}.
+ * {@link #startDownloadIfReady(ExecutorService)}.
*/
@GuardedBy("this")
- private Future<?> mActiveTask;
+ private Future<?> mActiveDownload;
private final Context mContext;
private final SystemFacade mSystemFacade;
@@ -312,7 +312,7 @@ public class DownloadInfo {
/**
* Returns whether this download should be enqueued.
*/
- private boolean isReadyToStart() {
+ private boolean isReadyToDownload() {
if (mControl == Downloads.Impl.CONTROL_PAUSED) {
// the download is paused, so it's not going to start
return false;
@@ -450,11 +450,14 @@ public class DownloadInfo {
* If download is ready to start, and isn't already pending or executing,
* create a {@link DownloadThread} and enqueue it into given
* {@link Executor}.
+ *
+ * @return If actively downloading.
*/
- public void startIfReady(ExecutorService executor) {
+ public boolean startDownloadIfReady(ExecutorService executor) {
synchronized (this) {
- final boolean isActive = mActiveTask != null && !mActiveTask.isDone();
- if (isReadyToStart() && !isActive) {
+ final boolean isReady = isReadyToDownload();
+ final boolean isActive = mActiveDownload != null && !mActiveDownload.isDone();
+ if (isReady && !isActive) {
if (mStatus != Impl.STATUS_RUNNING) {
mStatus = Impl.STATUS_RUNNING;
ContentValues values = new ContentValues();
@@ -464,8 +467,25 @@ public class DownloadInfo {
final DownloadThread task = new DownloadThread(
mContext, mSystemFacade, this, mStorageManager, mNotifier);
- mActiveTask = executor.submit(task);
+ mActiveDownload = executor.submit(task);
}
+ return isReady;
+ }
+ }
+
+ /**
+ * If download is ready to be scanned, enqueue it into the given
+ * {@link DownloadScanner}.
+ *
+ * @return If actively scanning.
+ */
+ public boolean startScanIfReady(DownloadScanner scanner) {
+ synchronized (this) {
+ final boolean isReady = shouldScanFile();
+ if (isReady) {
+ scanner.requestScan(this);
+ }
+ return isReady;
}
}
@@ -527,15 +547,15 @@ public class DownloadInfo {
}
/**
- * Returns the amount of time (as measured from the "now" parameter)
- * at which a download will be active.
- * 0 = immediately - service should stick around to handle this download.
- * -1 = never - service can go away without ever waking up.
- * positive value - service must wake up in the future, as specified in ms from "now"
+ * Return time when this download will be ready for its next action, in
+ * milliseconds after given time.
+ *
+ * @return If {@code 0}, download is ready to proceed immediately. If
+ * {@link Long#MAX_VALUE}, then download has no future actions.
*/
- long nextAction(long now) {
+ public long nextActionMillis(long now) {
if (Downloads.Impl.isStatusCompleted(mStatus)) {
- return -1;
+ return Long.MAX_VALUE;
}
if (mStatus != Downloads.Impl.STATUS_WAITING_TO_RETRY) {
return 0;
@@ -550,7 +570,7 @@ public class DownloadInfo {
/**
* Returns whether a file should be scanned
*/
- boolean shouldScanFile() {
+ public boolean shouldScanFile() {
return (mMediaScanned == 0)
&& (mDestination == Downloads.Impl.DESTINATION_EXTERNAL ||
mDestination == Downloads.Impl.DESTINATION_FILE_URI ||
diff --git a/src/com/android/providers/downloads/DownloadScanner.java b/src/com/android/providers/downloads/DownloadScanner.java
new file mode 100644
index 00000000..ca795062
--- /dev/null
+++ b/src/com/android/providers/downloads/DownloadScanner.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2013 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.text.format.DateUtils.MINUTE_IN_MILLIS;
+import static com.android.providers.downloads.Constants.LOGV;
+import static com.android.providers.downloads.Constants.TAG;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.media.MediaScannerConnection;
+import android.media.MediaScannerConnection.MediaScannerConnectionClient;
+import android.net.Uri;
+import android.os.SystemClock;
+import android.provider.Downloads;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.google.common.collect.Maps;
+
+import java.util.HashMap;
+
+/**
+ * Manages asynchronous scanning of completed downloads.
+ */
+public class DownloadScanner implements MediaScannerConnectionClient {
+ private static final long SCAN_TIMEOUT = MINUTE_IN_MILLIS;
+
+ private final Context mContext;
+ private final MediaScannerConnection mConnection;
+
+ private static class ScanRequest {
+ public final long id;
+ public final String path;
+ public final String mimeType;
+ public final long requestRealtime;
+
+ public ScanRequest(long id, String path, String mimeType) {
+ this.id = id;
+ this.path = path;
+ this.mimeType = mimeType;
+ this.requestRealtime = SystemClock.elapsedRealtime();
+ }
+
+ public void exec(MediaScannerConnection conn) {
+ conn.scanFile(path, mimeType);
+ }
+ }
+
+ @GuardedBy("mConnection")
+ private HashMap<String, ScanRequest> mPending = Maps.newHashMap();
+
+ public DownloadScanner(Context context) {
+ mContext = context;
+ mConnection = new MediaScannerConnection(context, this);
+ }
+
+ /**
+ * Check if requested scans are still pending. Scans may timeout after an
+ * internal duration.
+ */
+ public boolean hasPendingScans() {
+ synchronized (mConnection) {
+ if (mPending.isEmpty()) {
+ return false;
+ } else {
+ // Check if pending scans have timed out
+ final long nowRealtime = SystemClock.elapsedRealtime();
+ for (ScanRequest req : mPending.values()) {
+ if (nowRealtime < req.requestRealtime + SCAN_TIMEOUT) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Request that given {@link DownloadInfo} be scanned at some point in
+ * future. Enqueues the request to be scanned asynchronously.
+ *
+ * @see #hasPendingScans()
+ */
+ public void requestScan(DownloadInfo info) {
+ if (LOGV) Log.v(TAG, "requestScan() for " + info.mFileName);
+ synchronized (mConnection) {
+ final ScanRequest req = new ScanRequest(info.mId, info.mFileName, info.mMimeType);
+ mPending.put(req.path, req);
+
+ if (mConnection.isConnected()) {
+ req.exec(mConnection);
+ } else {
+ mConnection.connect();
+ }
+ }
+ }
+
+ public void shutdown() {
+ mConnection.disconnect();
+ }
+
+ @Override
+ public void onMediaScannerConnected() {
+ synchronized (mConnection) {
+ for (ScanRequest req : mPending.values()) {
+ req.exec(mConnection);
+ }
+ }
+ }
+
+ @Override
+ public void onScanCompleted(String path, Uri uri) {
+ final ScanRequest req;
+ synchronized (mConnection) {
+ req = mPending.remove(path);
+ }
+ if (req == null) {
+ Log.w(TAG, "Missing request for path " + path);
+ return;
+ }
+
+ // Update scanned column, which will kick off a database update pass,
+ // eventually deciding if overall service is ready for teardown.
+ final ContentValues values = new ContentValues();
+ values.put(Downloads.Impl.COLUMN_MEDIA_SCANNED, 1);
+ if (uri != null) {
+ values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, uri.toString());
+ }
+
+ final ContentResolver resolver = mContext.getContentResolver();
+ final Uri downloadUri = ContentUris.withAppendedId(
+ Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, req.id);
+ final int rows = resolver.update(downloadUri, values, null, null);
+ if (rows == 0) {
+ // Local row disappeared during scan; download was probably deleted
+ // so clean up now-orphaned media entry.
+ resolver.delete(uri, null, null);
+ }
+ }
+}
diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java
index 039f12cd..34b1b495 100644
--- a/src/com/android/providers/downloads/DownloadService.java
+++ b/src/com/android/providers/downloads/DownloadService.java
@@ -16,26 +16,25 @@
package com.android.providers.downloads;
+import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
import static com.android.providers.downloads.Constants.TAG;
import android.app.AlarmManager;
+import android.app.DownloadManager;
import android.app.PendingIntent;
import android.app.Service;
-import android.content.ComponentName;
-import android.content.ContentValues;
+import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
-import android.content.ServiceConnection;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.database.Cursor;
-import android.media.IMediaScannerListener;
-import android.media.IMediaScannerService;
import android.net.Uri;
import android.os.Handler;
+import android.os.HandlerThread;
import android.os.IBinder;
+import android.os.Message;
import android.os.Process;
-import android.os.RemoteException;
import android.provider.Downloads;
import android.text.TextUtils;
import android.util.Log;
@@ -45,12 +44,12 @@ import com.android.internal.util.IndentingPrintWriter;
import com.google.android.collect.Maps;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
import java.io.File;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.Collections;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -60,11 +59,25 @@ import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
- * Performs the background downloads requested by applications that use the Downloads provider.
+ * Performs background downloads as requested by applications that use
+ * {@link DownloadManager}. Multiple start commands can be issued at this
+ * service, and it will continue running until no downloads are being actively
+ * processed. It may schedule alarms to resume downloads in future.
+ * <p>
+ * Any database updates important enough to initiate tasks should always be
+ * delivered through {@link Context#startService(Intent)}.
*/
public class DownloadService extends Service {
- /** amount of time to wait to connect to MediaScannerService before timing out */
- private static final long WAIT_TIMEOUT = 10 * 1000;
+ // TODO: migrate WakeLock from individual DownloadThreads out into
+ // DownloadReceiver to protect our entire workflow.
+
+ private static final boolean DEBUG_LIFECYCLE = true;
+
+ @VisibleForTesting
+ SystemFacade mSystemFacade;
+
+ private AlarmManager mAlarmManager;
+ private StorageManager mStorageManager;
/** Observer to get notified when the content observer's data changes */
private DownloadManagerContentObserver mObserver;
@@ -79,7 +92,7 @@ public class DownloadService extends Service {
* content provider changes or disappears.
*/
@GuardedBy("mDownloads")
- private Map<Long, DownloadInfo> mDownloads = Maps.newHashMap();
+ private final Map<Long, DownloadInfo> mDownloads = Maps.newHashMap();
private final ExecutorService mExecutor = buildDownloadExecutor();
@@ -96,115 +109,24 @@ public class DownloadService extends Service {
return executor;
}
- /**
- * The thread that updates the internal download list from the content
- * provider.
- */
- private UpdateThread mUpdateThread;
+ private DownloadScanner mScanner;
- /**
- * Whether the internal download list should be updated from the content
- * provider.
- */
- private boolean mPendingUpdate;
+ private HandlerThread mUpdateThread;
+ private Handler mUpdateHandler;
- /**
- * The ServiceConnection object that tells us when we're connected to and disconnected from
- * the Media Scanner
- */
- private MediaScannerConnection mMediaScannerConnection;
-
- private boolean mMediaScannerConnecting;
-
- /**
- * The IPC interface to the Media Scanner
- */
- private IMediaScannerService mMediaScannerService;
-
- @VisibleForTesting
- SystemFacade mSystemFacade;
-
- private StorageManager mStorageManager;
+ private volatile int mLastStartId;
/**
* Receives notifications when the data in the content provider changes
*/
private class DownloadManagerContentObserver extends ContentObserver {
-
public DownloadManagerContentObserver() {
super(new Handler());
}
- /**
- * Receives notification when the data in the observed content
- * provider changes.
- */
@Override
public void onChange(final boolean selfChange) {
- if (Constants.LOGVV) {
- Log.v(Constants.TAG, "Service ContentObserver received notification");
- }
- updateFromProvider();
- }
-
- }
-
- /**
- * Gets called back when the connection to the media
- * scanner is established or lost.
- */
- public class MediaScannerConnection implements ServiceConnection {
- public void onServiceConnected(ComponentName className, IBinder service) {
- if (Constants.LOGVV) {
- Log.v(Constants.TAG, "Connected to Media Scanner");
- }
- synchronized (DownloadService.this) {
- try {
- mMediaScannerConnecting = false;
- mMediaScannerService = IMediaScannerService.Stub.asInterface(service);
- if (mMediaScannerService != null) {
- updateFromProvider();
- }
- } finally {
- // notify anyone waiting on successful connection to MediaService
- DownloadService.this.notifyAll();
- }
- }
- }
-
- public void disconnectMediaScanner() {
- synchronized (DownloadService.this) {
- mMediaScannerConnecting = false;
- if (mMediaScannerService != null) {
- mMediaScannerService = null;
- if (Constants.LOGVV) {
- Log.v(Constants.TAG, "Disconnecting from Media Scanner");
- }
- try {
- unbindService(this);
- } catch (IllegalArgumentException ex) {
- Log.w(Constants.TAG, "unbindService failed: " + ex);
- } finally {
- // notify anyone waiting on unsuccessful connection to MediaService
- DownloadService.this.notifyAll();
- }
- }
- }
- }
-
- public void onServiceDisconnected(ComponentName className) {
- try {
- if (Constants.LOGVV) {
- Log.v(Constants.TAG, "Disconnected from Media Scanner");
- }
- } finally {
- synchronized (DownloadService.this) {
- mMediaScannerService = null;
- mMediaScannerConnecting = false;
- // notify anyone waiting on disconnect from MediaService
- DownloadService.this.notifyAll();
- }
- }
+ enqueueUpdate();
}
}
@@ -233,19 +155,21 @@ public class DownloadService extends Service {
mSystemFacade = new RealSystemFacade(this);
}
- mObserver = new DownloadManagerContentObserver();
- getContentResolver().registerContentObserver(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
- true, mObserver);
+ mAlarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ mStorageManager = new StorageManager(this);
+
+ mUpdateThread = new HandlerThread(TAG + "-UpdateThread");
+ mUpdateThread.start();
+ mUpdateHandler = new Handler(mUpdateThread.getLooper(), mUpdateCallback);
- mMediaScannerService = null;
- mMediaScannerConnecting = false;
- mMediaScannerConnection = new MediaScannerConnection();
+ mScanner = new DownloadScanner(this);
mNotifier = new DownloadNotifier(this);
mNotifier.cancelAll();
- mStorageManager = new StorageManager(this);
- updateFromProvider();
+ mObserver = new DownloadManagerContentObserver();
+ getContentResolver().registerContentObserver(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
+ true, mObserver);
}
@Override
@@ -254,15 +178,14 @@ public class DownloadService extends Service {
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Service onStart");
}
- updateFromProvider();
+ mLastStartId = startId;
+ enqueueUpdate();
return returnValue;
}
- /**
- * Cleans up when the service is destroyed
- */
@Override
public void onDestroy() {
+ mScanner.shutdown();
getContentResolver().unregisterContentObserver(mObserver);
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Service onDestroy");
@@ -271,182 +194,167 @@ public class DownloadService extends Service {
}
/**
- * Parses data from the content provider into private array
+ * Enqueue an {@link #updateLocked()} pass to occur in future.
*/
- private void updateFromProvider() {
- synchronized (this) {
- mPendingUpdate = true;
- if (mUpdateThread == null) {
- mUpdateThread = new UpdateThread();
- mUpdateThread.start();
- }
- }
+ private void enqueueUpdate() {
+ mUpdateHandler.removeMessages(MSG_UPDATE);
+ mUpdateHandler.obtainMessage(MSG_UPDATE, mLastStartId, -1).sendToTarget();
}
- private class UpdateThread extends Thread {
- public UpdateThread() {
- super("Download Service");
- }
+ /**
+ * Enqueue an {@link #updateLocked()} pass to occur after delay, usually to
+ * catch any finished operations that didn't trigger an update pass.
+ */
+ private void enqueueFinalUpdate() {
+ mUpdateHandler.removeMessages(MSG_FINAL_UPDATE);
+ mUpdateHandler.sendMessageDelayed(
+ mUpdateHandler.obtainMessage(MSG_FINAL_UPDATE, mLastStartId, -1),
+ MINUTE_IN_MILLIS);
+ }
+
+ private static final int MSG_UPDATE = 1;
+ private static final int MSG_FINAL_UPDATE = 2;
+ private Handler.Callback mUpdateCallback = new Handler.Callback() {
@Override
- public void run() {
+ public boolean handleMessage(Message msg) {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
- boolean keepService = false;
- // for each update from the database, remember which download is
- // supposed to get restarted soonest in the future
- long wakeUp = Long.MAX_VALUE;
- for (;;) {
- synchronized (DownloadService.this) {
- if (mUpdateThread != this) {
- throw new IllegalStateException(
- "multiple UpdateThreads in DownloadService");
- }
- if (!mPendingUpdate) {
- mUpdateThread = null;
- if (!keepService) {
- stopSelf();
- }
- if (wakeUp != Long.MAX_VALUE) {
- scheduleAlarm(wakeUp);
- }
- return;
- }
- mPendingUpdate = false;
+
+ final int startId = msg.arg1;
+ if (DEBUG_LIFECYCLE) Log.v(TAG, "Updating for startId " + startId);
+
+ // Since database is current source of truth, our "active" status
+ // depends on database state. We always get one final update pass
+ // once the real actions have finished and persisted their state.
+
+ // TODO: switch to asking real tasks to derive active state
+ // TODO: handle media scanner timeouts
+
+ final boolean isActive;
+ synchronized (mDownloads) {
+ isActive = updateLocked();
+ }
+
+ if (msg.what == MSG_FINAL_UPDATE) {
+ Log.wtf(TAG, "Final update pass triggered, isActive=" + isActive
+ + "; someone didn't update correctly.");
+ }
+
+ if (isActive) {
+ // Still doing useful work, keep service alive. These active
+ // tasks will trigger another update pass when they're finished.
+
+ // Enqueue delayed update pass to catch finished operations that
+ // didn't trigger an update pass; these are bugs.
+ enqueueFinalUpdate();
+
+ } else {
+ // No active tasks, and any pending update messages can be
+ // ignored, since any updates important enough to initiate tasks
+ // will always be delivered with a new startId.
+
+ if (stopSelfResult(startId)) {
+ if (DEBUG_LIFECYCLE) Log.v(TAG, "Nothing left; stopped");
+ mUpdateHandler.removeMessages(MSG_UPDATE);
+ mUpdateHandler.removeMessages(MSG_FINAL_UPDATE);
}
+ }
- synchronized (mDownloads) {
- long now = mSystemFacade.currentTimeMillis();
- boolean mustScan = false;
- keepService = false;
- wakeUp = Long.MAX_VALUE;
- Set<Long> idsNoLongerInDatabase = new HashSet<Long>(mDownloads.keySet());
-
- Cursor cursor = getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
- null, null, null, null);
- if (cursor == null) {
- continue;
- }
- try {
- DownloadInfo.Reader reader =
- new DownloadInfo.Reader(getContentResolver(), cursor);
- int idColumn = cursor.getColumnIndexOrThrow(Downloads.Impl._ID);
- if (Constants.LOGVV) {
- Log.i(Constants.TAG, "number of rows from downloads-db: " +
- cursor.getCount());
- }
- for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
- long id = cursor.getLong(idColumn);
- idsNoLongerInDatabase.remove(id);
- DownloadInfo info = mDownloads.get(id);
- if (info != null) {
- updateDownload(reader, info, now);
- } else {
- info = insertDownloadLocked(reader, now);
- }
-
- if (info.shouldScanFile() && !scanFile(info, true, false)) {
- mustScan = true;
- keepService = true;
- }
- if (info.hasCompletionNotification()) {
- keepService = true;
- }
- long next = info.nextAction(now);
- if (next == 0) {
- keepService = true;
- } else if (next > 0 && next < wakeUp) {
- wakeUp = next;
- }
- }
- } finally {
- cursor.close();
- }
+ return true;
+ }
+ };
- for (Long id : idsNoLongerInDatabase) {
- deleteDownloadLocked(id);
- }
+ /**
+ * Update {@link #mDownloads} to match {@link DownloadProvider} state.
+ * Depending on current download state it may enqueue {@link DownloadThread}
+ * instances, request {@link DownloadScanner} scans, update user-visible
+ * notifications, and/or schedule future actions with {@link AlarmManager}.
+ * <p>
+ * Should only be called from {@link #mUpdateThread} as after being
+ * requested through {@link #enqueueUpdate()}.
+ *
+ * @return If there are active tasks being processed, as of the database
+ * snapshot taken in this update.
+ */
+ private boolean updateLocked() {
+ final long now = mSystemFacade.currentTimeMillis();
- // is there a need to start the DownloadService? yes, if there are rows to be
- // deleted.
- if (!mustScan) {
- for (DownloadInfo info : mDownloads.values()) {
- if (info.mDeleted && TextUtils.isEmpty(info.mMediaProviderUri)) {
- mustScan = true;
- keepService = true;
- break;
- }
- }
- }
- mNotifier.updateWith(mDownloads.values());
- if (mustScan) {
- bindMediaScanner();
- } else {
- mMediaScannerConnection.disconnectMediaScanner();
+ boolean isActive = false;
+ long nextActionMillis = Long.MAX_VALUE;
+
+ final Set<Long> staleIds = Sets.newHashSet(mDownloads.keySet());
+
+ final ContentResolver resolver = getContentResolver();
+ final Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
+ null, null, null, null);
+ try {
+ final DownloadInfo.Reader reader = new DownloadInfo.Reader(resolver, cursor);
+ final int idColumn = cursor.getColumnIndexOrThrow(Downloads.Impl._ID);
+ while (cursor.moveToNext()) {
+ final long id = cursor.getLong(idColumn);
+ staleIds.remove(id);
+
+ DownloadInfo info = mDownloads.get(id);
+ if (info != null) {
+ updateDownload(reader, info, now);
+ } else {
+ info = insertDownloadLocked(reader, now);
+ }
+
+ if (info.mDeleted) {
+ // Delete download if requested, but only after cleaning up
+ if (!TextUtils.isEmpty(info.mMediaProviderUri)) {
+ resolver.delete(Uri.parse(info.mMediaProviderUri), null, null);
}
- // look for all rows with deleted flag set and delete the rows from the database
- // permanently
- for (DownloadInfo info : mDownloads.values()) {
- if (info.mDeleted) {
- // this row is to be deleted from the database. but does it have
- // mediaProviderUri?
- if (TextUtils.isEmpty(info.mMediaProviderUri)) {
- if (info.shouldScanFile()) {
- // initiate rescan of the file to - which will populate
- // mediaProviderUri column in this row
- if (!scanFile(info, false, true)) {
- throw new IllegalStateException("scanFile failed!");
- }
- continue;
- }
- } else {
- // yes it has mediaProviderUri column already filled in.
- // delete it from MediaProvider database.
- getContentResolver().delete(Uri.parse(info.mMediaProviderUri), null,
- null);
- }
- // delete the file
- deleteFileIfExists(info.mFileName);
- // delete from the downloads db
- getContentResolver().delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
- Downloads.Impl._ID + " = ? ",
- new String[]{String.valueOf(info.mId)});
- }
+ deleteFileIfExists(info.mFileName);
+ resolver.delete(info.getAllDownloadsUri(), null, null);
+
+ } else {
+ // Kick off download task if ready
+ final boolean activeDownload = info.startDownloadIfReady(mExecutor);
+
+ // Kick off media scan if completed
+ final boolean activeScan = info.startScanIfReady(mScanner);
+
+ if (DEBUG_LIFECYCLE && (activeDownload || activeScan)) {
+ Log.v(TAG, "Download " + info.mId + ": activeDownload=" + activeDownload
+ + ", activeScan=" + activeScan);
}
+
+ isActive |= activeDownload;
+ isActive |= activeScan;
}
+
+ // Keep track of nearest next action
+ nextActionMillis = Math.min(info.nextActionMillis(now), nextActionMillis);
}
+ } finally {
+ cursor.close();
}
- private void bindMediaScanner() {
- if (!mMediaScannerConnecting) {
- Intent intent = new Intent();
- intent.setClassName("com.android.providers.media",
- "com.android.providers.media.MediaScannerService");
- mMediaScannerConnecting = true;
- bindService(intent, mMediaScannerConnection, BIND_AUTO_CREATE);
- }
+ // Clean up stale downloads that disappeared
+ for (Long id : staleIds) {
+ deleteDownloadLocked(id);
}
- private void scheduleAlarm(long wakeUp) {
- AlarmManager alarms = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
- if (alarms == null) {
- Log.e(Constants.TAG, "couldn't get alarm manager");
- return;
- }
+ // Update notifications visible to user
+ mNotifier.updateWith(mDownloads.values());
+ // Set alarm when next action is in future. It's okay if the service
+ // continues to run in meantime, since it will kick off an update pass.
+ if (nextActionMillis > 0 && nextActionMillis < Long.MAX_VALUE) {
if (Constants.LOGV) {
- Log.v(Constants.TAG, "scheduling retry in " + wakeUp + "ms");
+ Log.v(TAG, "scheduling start in " + nextActionMillis + "ms");
}
- Intent intent = new Intent(Constants.ACTION_RETRY);
- intent.setClassName("com.android.providers.downloads",
- DownloadReceiver.class.getName());
- alarms.set(
- AlarmManager.RTC_WAKEUP,
- mSystemFacade.currentTimeMillis() + wakeUp,
- PendingIntent.getBroadcast(DownloadService.this, 0, intent,
- PendingIntent.FLAG_ONE_SHOT));
+ final Intent intent = new Intent(Constants.ACTION_RETRY);
+ intent.setClass(this, DownloadReceiver.class);
+ mAlarmManager.set(AlarmManager.RTC_WAKEUP, now + nextActionMillis,
+ PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_ONE_SHOT));
}
+
+ return isActive;
}
/**
@@ -462,7 +370,6 @@ public class DownloadService extends Service {
Log.v(Constants.TAG, "processing inserted download " + info.mId);
}
- info.startIfReady(mExecutor);
return info;
}
@@ -470,15 +377,11 @@ public class DownloadService extends Service {
* Updates the local copy of the info about a download.
*/
private void updateDownload(DownloadInfo.Reader reader, DownloadInfo info, long now) {
- int oldVisibility = info.mVisibility;
- int oldStatus = info.mStatus;
-
reader.updateFromDatabase(info);
if (Constants.LOGVV) {
Log.v(Constants.TAG, "processing updated download " + info.mId +
", status: " + info.mStatus);
}
- info.startIfReady(mExecutor);
}
/**
@@ -493,88 +396,20 @@ public class DownloadService extends Service {
if (Constants.LOGVV) {
Log.d(TAG, "deleteDownloadLocked() deleting " + info.mFileName);
}
- new File(info.mFileName).delete();
+ deleteFileIfExists(info.mFileName);
}
mDownloads.remove(info.mId);
}
- /**
- * Attempts to scan the file if necessary.
- * @return true if the file has been properly scanned.
- */
- private boolean scanFile(DownloadInfo info, final boolean updateDatabase,
- final boolean deleteFile) {
- synchronized (this) {
- if (mMediaScannerService == null) {
- // not bound to mediaservice. but if in the process of connecting to it, wait until
- // connection is resolved
- while (mMediaScannerConnecting) {
- Log.d(Constants.TAG, "waiting for mMediaScannerService service: ");
- try {
- this.wait(WAIT_TIMEOUT);
- } catch (InterruptedException e1) {
- throw new IllegalStateException("wait interrupted");
- }
- }
- }
- // do we have mediaservice?
- if (mMediaScannerService == null) {
- // no available MediaService And not even in the process of connecting to it
- return false;
- }
- if (Constants.LOGV) {
- Log.v(Constants.TAG, "Scanning file " + info.mFileName);
- }
- try {
- final Uri key = info.getAllDownloadsUri();
- final long id = info.mId;
- mMediaScannerService.requestScanFile(info.mFileName, info.mMimeType,
- new IMediaScannerListener.Stub() {
- public void scanCompleted(String path, Uri uri) {
- if (updateDatabase) {
- // Mark this as 'scanned' in the database
- // so that it is NOT subject to re-scanning by MediaScanner
- // next time this database row row is encountered
- ContentValues values = new ContentValues();
- values.put(Constants.MEDIA_SCANNED, 1);
- if (uri != null) {
- values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI,
- uri.toString());
- }
- getContentResolver().update(key, values, null, null);
- } else if (deleteFile) {
- if (uri != null) {
- // use the Uri returned to delete it from the MediaProvider
- getContentResolver().delete(uri, null, null);
- }
- // delete the file and delete its row from the downloads db
- deleteFileIfExists(path);
- getContentResolver().delete(
- Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
- Downloads.Impl._ID + " = ? ",
- new String[]{String.valueOf(id)});
- }
- }
- });
- return true;
- } catch (RemoteException e) {
- Log.w(Constants.TAG, "Failed to scan file " + info.mFileName);
- return false;
- }
- }
- }
-
private void deleteFileIfExists(String path) {
- try {
- if (!TextUtils.isEmpty(path)) {
- if (Constants.LOGVV) {
- Log.d(TAG, "deleteFileIfExists() deleting " + path);
- }
- File file = new File(path);
- file.delete();
+ if (!TextUtils.isEmpty(path)) {
+ if (Constants.LOGVV) {
+ Log.d(TAG, "deleteFileIfExists() deleting " + path);
+ }
+ final File file = new File(path);
+ if (file.exists() && !file.delete()) {
+ Log.w(TAG, "file: '" + path + "' couldn't be deleted");
}
- } catch (Exception e) {
- Log.w(Constants.TAG, "file: '" + path + "' couldn't be deleted", e);
}
}
diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java
index 0d427fdd..c60b02a0 100644
--- a/src/com/android/providers/downloads/DownloadThread.java
+++ b/src/com/android/providers/downloads/DownloadThread.java
@@ -20,7 +20,9 @@ import static android.provider.Downloads.Impl.STATUS_BAD_REQUEST;
import static android.provider.Downloads.Impl.STATUS_CANNOT_RESUME;
import static android.provider.Downloads.Impl.STATUS_FILE_ERROR;
import static android.provider.Downloads.Impl.STATUS_HTTP_DATA_ERROR;
+import static android.provider.Downloads.Impl.STATUS_QUEUED_FOR_WIFI;
import static android.provider.Downloads.Impl.STATUS_TOO_MANY_REDIRECTS;
+import static android.provider.Downloads.Impl.STATUS_WAITING_FOR_NETWORK;
import static android.provider.Downloads.Impl.STATUS_WAITING_TO_RETRY;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
import static com.android.providers.downloads.Constants.TAG;
@@ -29,7 +31,6 @@ import static java.net.HttpURLConnection.HTTP_MOVED_PERM;
import static java.net.HttpURLConnection.HTTP_MOVED_TEMP;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_PARTIAL;
-import static java.net.HttpURLConnection.HTTP_PRECON_FAILED;
import static java.net.HttpURLConnection.HTTP_SEE_OTHER;
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
@@ -218,11 +219,13 @@ public class DownloadThread implements Runnable {
}
finalStatus = error.getFinalStatus();
+ // Nobody below our level should request retries, since we handle
+ // failure counts at this level.
if (finalStatus == STATUS_WAITING_TO_RETRY) {
throw new IllegalStateException("Execution should always throw final error codes");
}
- // Some errors should be retryable later, unless we fail too many times.
+ // Some errors should be retryable, unless we fail too many times.
if (isStatusRetryable(finalStatus)) {
if (state.mGotData) {
numFailed = 1;
@@ -231,7 +234,7 @@ public class DownloadThread implements Runnable {
}
if (numFailed < Constants.MAX_RETRIES) {
- finalStatus = STATUS_WAITING_TO_RETRY;
+ finalStatus = getFinalRetryStatus();
}
}
@@ -428,6 +431,21 @@ public class DownloadThread implements Runnable {
}
/**
+ * Return retry status appropriate for current network conditions.
+ */
+ private int getFinalRetryStatus() {
+ switch (mInfo.checkCanUseNetwork()) {
+ case OK:
+ return STATUS_WAITING_TO_RETRY;
+ case UNUSABLE_DUE_TO_SIZE:
+ case RECOMMENDED_UNUSABLE_DUE_TO_SIZE:
+ return STATUS_QUEUED_FOR_WIFI;
+ default:
+ return STATUS_WAITING_FOR_NETWORK;
+ }
+ }
+
+ /**
* Transfer as much data as possible from the HTTP response to the
* destination file.
*/
@@ -805,10 +823,10 @@ public class DownloadThread implements Runnable {
*/
private void notifyDownloadCompleted(
State state, int finalStatus, String errorMsg, int numFailed) {
- notifyThroughDatabase(state, finalStatus, errorMsg, numFailed);
if (Downloads.Impl.isStatusCompleted(finalStatus)) {
mInfo.sendIntentIfRequested();
}
+ notifyThroughDatabase(state, finalStatus, errorMsg, numFailed);
}
private void notifyThroughDatabase(
diff --git a/tests/Android.mk b/tests/Android.mk
index 4b20631b..655ec168 100644
--- a/tests/Android.mk
+++ b/tests/Android.mk
@@ -8,7 +8,7 @@ LOCAL_MODULE_TAGS := tests
LOCAL_SRC_FILES := $(call all-java-files-under, src)
LOCAL_INSTRUMENTATION_FOR := DownloadProvider
LOCAL_JAVA_LIBRARIES := android.test.runner
-LOCAL_STATIC_JAVA_LIBRARIES := mockwebserver mockito-target
+LOCAL_STATIC_JAVA_LIBRARIES := mockwebserver dexmaker mockito-target
LOCAL_PACKAGE_NAME := DownloadProviderTests
LOCAL_CERTIFICATE := media
diff --git a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
index 0074a270..3b937389 100644
--- a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
+++ b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java
@@ -56,6 +56,8 @@ public abstract class AbstractDownloadProviderFunctionalTest extends
protected static final String
FILE_CONTENT = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ private final MockitoHelper mMockitoHelper = new MockitoHelper();
+
protected MockWebServer mServer;
protected MockContentResolverWithNotify mResolver;
protected TestContext mTestContext;
@@ -147,6 +149,7 @@ public abstract class AbstractDownloadProviderFunctionalTest extends
@Override
protected void setUp() throws Exception {
super.setUp();
+ mMockitoHelper.setUp(getClass());
// Since we're testing a system app, AppDataDirGuesser doesn't find our
// cache dir, so set it explicitly.
@@ -169,6 +172,7 @@ public abstract class AbstractDownloadProviderFunctionalTest extends
protected void tearDown() throws Exception {
cleanUpDownloads();
mServer.shutdown();
+ mMockitoHelper.tearDown();
super.tearDown();
}
diff --git a/tests/src/com/android/providers/downloads/MockitoHelper.java b/tests/src/com/android/providers/downloads/MockitoHelper.java
new file mode 100644
index 00000000..485128d8
--- /dev/null
+++ b/tests/src/com/android/providers/downloads/MockitoHelper.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2013 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 android.util.Log;
+
+/**
+ * Helper for Mockito-based test cases.
+ */
+public final class MockitoHelper {
+ private static final String TAG = "MockitoHelper";
+
+ private ClassLoader mOriginalClassLoader;
+ private Thread mContextThread;
+
+ /**
+ * Creates a new helper, which in turn will set the context classloader so
+ * it can load Mockito resources.
+ *
+ * @param packageClass test case class
+ */
+ public void setUp(Class<?> packageClass) throws Exception {
+ // makes a copy of the context classloader
+ mContextThread = Thread.currentThread();
+ mOriginalClassLoader = mContextThread.getContextClassLoader();
+ ClassLoader newClassLoader = packageClass.getClassLoader();
+ Log.v(TAG, "Changing context classloader from " + mOriginalClassLoader
+ + " to " + newClassLoader);
+ mContextThread.setContextClassLoader(newClassLoader);
+ }
+
+ /**
+ * Restores the context classloader to the previous value.
+ */
+ public void tearDown() throws Exception {
+ Log.v(TAG, "Restoring context classloader to " + mOriginalClassLoader);
+ mContextThread.setContextClassLoader(mOriginalClassLoader);
+ }
+}