From 5218d33d57990c3e3549c58bd3f0ac244dfc3d59 Mon Sep 17 00:00:00 2001 From: Vasu Nori Date: Thu, 16 Dec 2010 18:31:23 -0800 Subject: bug:3286430 set quota on downloads data dir make sure the doanloads data dir size is limited by some quote - 100MB default and 200MB for SR. bug:3286430 tests are in Change-Id: I688f7e058511089bec7fa21e972e23780604d98a Change-Id: Iba7fab9fa91ea018f35e1c3ef5ec0e6b03cba650 --- src/com/android/providers/downloads/Constants.java | 3 +- .../android/providers/downloads/DownloadInfo.java | 6 +- .../providers/downloads/DownloadProvider.java | 6 +- .../providers/downloads/DownloadService.java | 114 +----- .../providers/downloads/DownloadThread.java | 202 +++++---- src/com/android/providers/downloads/Helpers.java | 224 +--------- .../providers/downloads/StopRequestException.java | 37 ++ .../providers/downloads/StorageManager.java | 452 +++++++++++++++++++++ .../downloads/DownloadManagerFunctionalTest.java | 2 +- .../downloads/PublicApiFunctionalTest.java | 5 - 10 files changed, 624 insertions(+), 427 deletions(-) create mode 100644 src/com/android/providers/downloads/StopRequestException.java create mode 100644 src/com/android/providers/downloads/StorageManager.java diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java index da2c2fd9..6419a5e6 100644 --- a/src/com/android/providers/downloads/Constants.java +++ b/src/com/android/providers/downloads/Constants.java @@ -16,6 +16,7 @@ package com.android.providers.downloads; +import android.os.Environment; import android.util.Log; /** @@ -79,7 +80,7 @@ public class Constants { public static final String FILENAME_SEQUENCE_SEPARATOR = "-"; /** Where we store downloaded files on the external storage */ - public static final String DEFAULT_DL_SUBDIR = "/download"; + public static final String DEFAULT_DL_SUBDIR = "/" + Environment.DIRECTORY_DOWNLOADS; /** A magic filename that is allowed to exist within the system cache */ public static final String KNOWN_SPURIOUS_FILENAME = "lost+found"; diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java index d1ea43e2..ca43ea99 100644 --- a/src/com/android/providers/downloads/DownloadInfo.java +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -22,7 +22,6 @@ import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; -import android.database.CharArrayBuffer; import android.database.Cursor; import android.drm.mobile1.DrmRawContent; import android.net.ConnectivityManager; @@ -427,7 +426,7 @@ public class DownloadInfo { return NETWORK_OK; } - void startIfReady(long now) { + void startIfReady(long now, StorageManager storageManager) { if (!isReadyToStart(now)) { return; } @@ -444,7 +443,8 @@ public class DownloadInfo { values.put(Impl.COLUMN_STATUS, mStatus); mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null); } - DownloadThread downloader = new DownloadThread(mContext, mSystemFacade, this); + DownloadThread downloader = new DownloadThread(mContext, mSystemFacade, this, + storageManager); mHasActiveThread = true; mSystemFacade.startThread(downloader); } diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java index d848b65b..a9952533 100644 --- a/src/com/android/providers/downloads/DownloadProvider.java +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -422,7 +422,11 @@ public final class DownloadProvider extends ContentProvider { if (appInfo != null) { mDefContainerUid = appInfo.uid; } - mDownloadsDataDir = Helpers.getDownloadsDataDirectory(getContext()); + // start the DownloadService class. don't wait for the 1st download to be issued. + // saves us by getting some initialization code in DownloadService out of the way. + Context context = getContext(); + context.startService(new Intent(context, DownloadService.class)); + mDownloadsDataDir = StorageManager.getInstance(getContext()).getDownloadDataDirectory(); return true; } diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java index 62e355c4..bc4083c7 100644 --- a/src/com/android/providers/downloads/DownloadService.java +++ b/src/com/android/providers/downloads/DownloadService.java @@ -16,23 +16,23 @@ package com.android.providers.downloads; +import com.google.android.collect.Maps; +import com.google.common.annotations.VisibleForTesting; + import android.app.AlarmManager; import android.app.PendingIntent; import android.app.Service; import android.content.ComponentName; import android.content.ContentResolver; -import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.database.ContentObserver; import android.database.Cursor; -import android.database.sqlite.SQLiteException; import android.media.IMediaScannerListener; import android.media.IMediaScannerService; import android.net.Uri; -import android.os.Environment; import android.os.Handler; import android.os.IBinder; import android.os.Process; @@ -41,12 +41,8 @@ import android.provider.Downloads; import android.text.TextUtils; import android.util.Log; -import com.google.android.collect.Maps; -import com.google.common.annotations.VisibleForTesting; - import java.io.File; import java.util.HashSet; -import java.util.Iterator; import java.util.Map; import java.util.Set; @@ -93,18 +89,16 @@ public class DownloadService extends Service { private boolean mMediaScannerConnecting; - private static final int LOCATION_SYSTEM_CACHE = 1; - private static final int LOCATION_DOWNLOAD_DATA_DIR = 2; - /** * The IPC interface to the Media Scanner */ private IMediaScannerService mMediaScannerService; - private File mDownloadsDataDir; @VisibleForTesting SystemFacade mSystemFacade; + private StorageManager mStorageManager; + /** * Receives notifications when the data in the content provider changes */ @@ -222,7 +216,7 @@ public class DownloadService extends Service { mNotifier = new DownloadNotification(this, mSystemFacade); mSystemFacade.cancelAllNotifications(); - mDownloadsDataDir = Helpers.getDownloadsDataDirectory(getApplicationContext()); + mStorageManager = StorageManager.getInstance(getApplicationContext()); updateFromProvider(); } @@ -269,13 +263,6 @@ public class DownloadService extends Service { @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); - - trimDatabase(); - // remove spurious files from system cache - removeSpuriousFiles(LOCATION_SYSTEM_CACHE); - // remove spurious files from downloads dir - removeSpuriousFiles(LOCATION_DOWNLOAD_DATA_DIR); - boolean keepService = false; // for each update from the database, remember which download is // supposed to get restarted soonest in the future @@ -429,91 +416,6 @@ public class DownloadService extends Service { } } - /** - * Removes files that may have been left behind in the systemcache or - * /data/downloads directory - */ - private void removeSpuriousFiles(int location) { - File base = (location == LOCATION_SYSTEM_CACHE) ? - Environment.getDownloadCacheDirectory() : mDownloadsDataDir; - File[] files = base.listFiles(); - if (files == null) { - // The cache folder doesn't appear to exist (this is likely the case - // when running the simulator). - return; - } - HashSet fileSet = new HashSet(); - for (int i = 0; i < files.length; i++) { - if (files[i].getName().equals(Constants.KNOWN_SPURIOUS_FILENAME)) { - continue; - } - if (files[i].getName().equalsIgnoreCase(Constants.RECOVERY_DIRECTORY)) { - continue; - } - fileSet.add(files[i].getPath()); - } - - Cursor cursor = getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - new String[] { Downloads.Impl._DATA }, null, null, null); - if (cursor != null) { - if (cursor.moveToFirst()) { - do { - fileSet.remove(cursor.getString(0)); - } while (cursor.moveToNext()); - } - cursor.close(); - } - Iterator iterator = fileSet.iterator(); - while (iterator.hasNext()) { - String filename = iterator.next(); - if (Constants.LOGV) { - Log.v(Constants.TAG, "deleting spurious file " + filename); - } - new File(filename).delete(); - } - } - - /** - * Drops old rows from the database to prevent it from growing too large - */ - private void trimDatabase() { - Cursor cursor = null; - try { - cursor = getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - new String[] { Downloads.Impl._ID }, - Downloads.Impl.COLUMN_STATUS + " >= '200'", null, - Downloads.Impl.COLUMN_LAST_MODIFICATION); - if (cursor == null) { - // This isn't good - if we can't do basic queries in our database, nothing's gonna work - Log.e(Constants.TAG, "null cursor in trimDatabase"); - return; - } - if (cursor.moveToFirst()) { - int numDelete = cursor.getCount() - Constants.MAX_DOWNLOADS; - int columnId = cursor.getColumnIndexOrThrow(Downloads.Impl._ID); - while (numDelete > 0) { - Uri downloadUri = ContentUris.withAppendedId( - Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, cursor.getLong(columnId)); - getContentResolver().delete(downloadUri, null, null); - if (!cursor.moveToNext()) { - break; - } - numDelete--; - } - } - } catch (SQLiteException e) { - // trimming the database raised an exception. alright, ignore the exception - // and return silently. trimming database is not exactly a critical operation - // and there is no need to propagate the exception. - Log.w(Constants.TAG, "trimDatabase failed with exception: " + e.getMessage()); - return; - } finally { - if (cursor != null) { - cursor.close(); - } - } - } - /** * Keeps a local copy of the info about a download, and initiates the * download if appropriate. @@ -526,7 +428,7 @@ public class DownloadService extends Service { info.logVerboseInfo(); } - info.startIfReady(now); + info.startIfReady(now, mStorageManager); return info; } @@ -550,7 +452,7 @@ public class DownloadService extends Service { mSystemFacade.cancelNotification(info.mId); } - info.startIfReady(now); + info.startIfReady(now, mStorageManager); } /** diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java index c497d5cf..fefbe1d8 100644 --- a/src/com/android/providers/downloads/DownloadThread.java +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -52,14 +52,17 @@ import java.util.Locale; */ public class DownloadThread extends Thread { - private Context mContext; - private DownloadInfo mInfo; - private SystemFacade mSystemFacade; + private final Context mContext; + private final DownloadInfo mInfo; + private final SystemFacade mSystemFacade; + private final StorageManager mStorageManager; - public DownloadThread(Context context, SystemFacade systemFacade, DownloadInfo info) { + public DownloadThread(Context context, SystemFacade systemFacade, DownloadInfo info, + StorageManager storageManager) { mContext = context; mSystemFacade = systemFacade; mInfo = info; + mStorageManager = storageManager; } /** @@ -78,7 +81,7 @@ public class DownloadThread extends Thread { /** * State for the entire run() method. */ - private static class State { + static class State { public String mFilename; public FileOutputStream mStream; public String mMimeType; @@ -111,28 +114,6 @@ public class DownloadThread extends Thread { public long mTimeLastNotification = 0; } - /** - * Raised from methods called by run() to indicate that the current request should be stopped - * immediately. - * - * Note the message passed to this exception will be logged and therefore must be guaranteed - * not to contain any PII, meaning it generally can't include any information about the request - * URI, headers, or destination filename. - */ - private class StopRequest extends Throwable { - public int mFinalStatus; - - public StopRequest(int finalStatus, String message) { - super(message); - mFinalStatus = finalStatus; - } - - public StopRequest(int finalStatus, String message, Throwable throwable) { - super(message, throwable); - mFinalStatus = finalStatus; - } - } - /** * Raised from methods called by executeDownload() to indicate that the download should be * retried immediately. @@ -142,6 +123,7 @@ public class DownloadThread extends Thread { /** * Executes the download in a separate thread */ + @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); @@ -187,16 +169,22 @@ public class DownloadThread extends Thread { } finalizeDestinationFile(state); finalStatus = Downloads.Impl.STATUS_SUCCESS; - } catch (StopRequest error) { + } catch (StopRequestException error) { // remove the cause before printing, in case it contains PII errorMsg = "Aborting request for download " + mInfo.mId + ": " + error.getMessage(); Log.w(Constants.TAG, errorMsg); + if (Constants.LOGV) { + Log.w(Constants.TAG, errorMsg, error); + } finalStatus = error.mFinalStatus; // fall through to finally block } catch (Throwable ex) { //sometimes the socket code throws unchecked exceptions errorMsg = "Exception for id " + mInfo.mId + ": " + ex.getMessage(); - Log.w(Constants.TAG, "Exception for id " + mInfo.mId + ": " + ex); + Log.w(Constants.TAG, errorMsg); finalStatus = Downloads.Impl.STATUS_UNKNOWN_ERROR; + if (Constants.LOGV) { + Log.w(Constants.TAG, errorMsg, ex); + } // falls through to the code that reports an error } finally { if (wakeLock != null) { @@ -213,6 +201,7 @@ public class DownloadThread extends Thread { state.mNewUri, state.mMimeType, errorMsg); mInfo.mHasActiveThread = false; } + mStorageManager.incrementNumDownloadsSoFar(); } /** @@ -220,7 +209,7 @@ public class DownloadThread extends Thread { * and transfer the data to the destination file. */ private void executeDownload(State state, AndroidHttpClient client, HttpGet request) - throws StopRequest, RetryDownload { + throws StopRequestException, RetryDownload { InnerState innerState = new InnerState(); byte data[] = new byte[Constants.BUFFER_SIZE]; @@ -245,7 +234,7 @@ public class DownloadThread extends Thread { /** * Check if current connectivity is valid for this request. */ - private void checkConnectivity(State state) throws StopRequest { + private void checkConnectivity(State state) throws StopRequestException { int networkUsable = mInfo.checkCanUseNetwork(); if (networkUsable != DownloadInfo.NETWORK_OK) { int status = Downloads.Impl.STATUS_WAITING_FOR_NETWORK; @@ -256,7 +245,8 @@ public class DownloadThread extends Thread { status = Downloads.Impl.STATUS_QUEUED_FOR_WIFI; mInfo.notifyPauseDueToSize(false); } - throw new StopRequest(status, mInfo.getLogMessageForNetworkError(networkUsable)); + throw new StopRequestException(status, + mInfo.getLogMessageForNetworkError(networkUsable)); } } @@ -266,7 +256,7 @@ public class DownloadThread extends Thread { * @param entityStream stream for reading the HTTP response entity */ private void transferData(State state, InnerState innerState, byte[] data, - InputStream entityStream) throws StopRequest { + InputStream entityStream) throws StopRequestException { for (;;) { int bytesRead = readFromResponse(state, innerState, data, entityStream); if (bytesRead == -1) { // success, end of stream already reached @@ -291,7 +281,7 @@ public class DownloadThread extends Thread { /** * Called after a successful completion to take any necessary action on the downloaded file. */ - private void finalizeDestinationFile(State state) throws StopRequest { + private void finalizeDestinationFile(State state) throws StopRequestException { if (isDrmFile(state)) { transferToDrm(state); } else { @@ -352,13 +342,13 @@ public class DownloadThread extends Thread { /** * Transfer the downloaded destination file to the DRM store. */ - private void transferToDrm(State state) throws StopRequest { + private void transferToDrm(State state) throws StopRequestException { File file = new File(state.mFilename); Intent item = DrmStore.addDrmFile(mContext.getContentResolver(), file, null); file.delete(); if (item == null) { - throw new StopRequest(Downloads.Impl.STATUS_UNKNOWN_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_UNKNOWN_ERROR, "unable to add file to DrmProvider"); } else { state.mFilename = item.getDataString(); @@ -388,15 +378,15 @@ public class DownloadThread extends Thread { * Check if the download has been paused or canceled, stopping the request appropriately if it * has been. */ - private void checkPausedOrCanceled(State state) throws StopRequest { + private void checkPausedOrCanceled(State state) throws StopRequestException { synchronized (mInfo) { if (mInfo.mControl == Downloads.Impl.CONTROL_PAUSED) { - throw new StopRequest(Downloads.Impl.STATUS_PAUSED_BY_APP, + throw new StopRequestException(Downloads.Impl.STATUS_PAUSED_BY_APP, "download paused by owner"); } } if (mInfo.mStatus == Downloads.Impl.STATUS_CANCELED) { - throw new StopRequest(Downloads.Impl.STATUS_CANCELED, "download canceled"); + throw new StopRequestException(Downloads.Impl.STATUS_CANCELED, "download canceled"); } } @@ -423,12 +413,13 @@ public class DownloadThread extends Thread { * @param bytesRead how many bytes to write from the buffer */ private void writeDataToDestination(State state, byte[] data, int bytesRead) - throws StopRequest { + throws StopRequestException { for (;;) { try { if (state.mStream == null) { state.mStream = new FileOutputStream(state.mFilename, true); } + mStorageManager.verifySpaceBeforeWritingToFile(mInfo.mDestination, null, bytesRead); state.mStream.write(data, 0, bytesRead); if (mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL && !isDrmFile(state)) { @@ -436,24 +427,7 @@ public class DownloadThread extends Thread { } return; } catch (IOException ex) { - if (mInfo.isOnCache()) { - if (Helpers.discardPurgeableFiles(mInfo.mDestination, mContext, - Constants.BUFFER_SIZE)) { - continue; - } - } else if (!Helpers.isExternalMediaMounted()) { - throw new StopRequest(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR, - "external media not mounted while writing destination file"); - } - - long availableBytes = - Helpers.getAvailableBytes(Helpers.getFilesystemRoot(mContext, state.mFilename)); - if (availableBytes < bytesRead) { - throw new StopRequest(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, - "insufficient space while writing destination file", ex); - } - throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR, - "while writing destination file: " + ex.toString(), ex); + mStorageManager.verifySpace(mInfo.mDestination, null, bytesRead); } } } @@ -462,7 +436,7 @@ public class DownloadThread extends Thread { * Called when we've reached the end of the HTTP response stream, to update the database and * check for consistency. */ - private void handleEndOfStream(State state, InnerState innerState) throws StopRequest { + private void handleEndOfStream(State state, InnerState innerState) throws StopRequestException { ContentValues values = new ContentValues(); values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, innerState.mBytesSoFar); if (innerState.mHeaderContentLength == null) { @@ -474,10 +448,10 @@ public class DownloadThread extends Thread { && (innerState.mBytesSoFar != Integer.parseInt(innerState.mHeaderContentLength)); if (lengthMismatched) { if (cannotResume(innerState)) { - throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME, + throw new StopRequestException(Downloads.Impl.STATUS_CANNOT_RESUME, "mismatched content length"); } else { - throw new StopRequest(getFinalStatusForHttpError(state), + throw new StopRequestException(getFinalStatusForHttpError(state), "closed socket before end of file"); } } @@ -494,7 +468,7 @@ public class DownloadThread extends Thread { * @return the number of bytes actually read or -1 if the end of the stream has been reached */ private int readFromResponse(State state, InnerState innerState, byte[] data, - InputStream entityStream) throws StopRequest { + InputStream entityStream) throws StopRequestException { try { return entityStream.read(data); } catch (IOException ex) { @@ -505,10 +479,10 @@ public class DownloadThread extends Thread { if (cannotResume(innerState)) { String message = "while reading response: " + ex.toString() + ", can't resume interrupted download with no ETag"; - throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME, + throw new StopRequestException(Downloads.Impl.STATUS_CANNOT_RESUME, message, ex); } else { - throw new StopRequest(getFinalStatusForHttpError(state), + throw new StopRequestException(getFinalStatusForHttpError(state), "while reading response: " + ex.toString(), ex); } } @@ -519,12 +493,12 @@ public class DownloadThread extends Thread { * @return an InputStream to read the response entity */ private InputStream openResponseEntity(State state, HttpResponse response) - throws StopRequest { + throws StopRequestException { try { return response.getEntity().getContent(); } catch (IOException ex) { logNetworkState(); - throw new StopRequest(getFinalStatusForHttpError(state), + throw new StopRequestException(getFinalStatusForHttpError(state), "while getting entity: " + ex.toString(), ex); } } @@ -541,7 +515,7 @@ public class DownloadThread extends Thread { * file and updating the database. */ private void processResponseHeaders(State state, InnerState innerState, HttpResponse response) - throws StopRequest { + throws StopRequestException { if (innerState.mContinuingDownload) { // ignore response headers on resume requests return; @@ -549,25 +523,21 @@ public class DownloadThread extends Thread { readResponseHeaders(state, innerState, response); - try { - state.mFilename = Helpers.generateSaveFile( - mContext, - mInfo.mUri, - mInfo.mHint, - innerState.mHeaderContentDisposition, - innerState.mHeaderContentLocation, - state.mMimeType, - mInfo.mDestination, - (innerState.mHeaderContentLength != null) ? - Long.parseLong(innerState.mHeaderContentLength) : 0, - mInfo.mIsPublicApi); - } catch (Helpers.GenerateSaveFileError exc) { - throw new StopRequest(exc.mStatus, exc.mMessage); - } + state.mFilename = Helpers.generateSaveFile( + mContext, + mInfo.mUri, + mInfo.mHint, + innerState.mHeaderContentDisposition, + innerState.mHeaderContentLocation, + state.mMimeType, + mInfo.mDestination, + (innerState.mHeaderContentLength != null) ? + Long.parseLong(innerState.mHeaderContentLength) : 0, + mInfo.mIsPublicApi, mStorageManager); try { state.mStream = new FileOutputStream(state.mFilename); } catch (FileNotFoundException exc) { - throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, "while opening destination file: " + exc.toString(), exc); } if (Constants.LOGV) { @@ -600,7 +570,7 @@ public class DownloadThread extends Thread { * Read headers from the HTTP response and store them into local state. */ private void readResponseHeaders(State state, InnerState innerState, HttpResponse response) - throws StopRequest { + throws StopRequestException { Header header = response.getFirstHeader("Content-Disposition"); if (header != null) { innerState.mHeaderContentDisposition = header.getValue(); @@ -651,7 +621,7 @@ public class DownloadThread extends Thread { && (headerTransferEncoding == null || !headerTransferEncoding.equalsIgnoreCase("chunked")); if (!mInfo.mNoIntegrity && noSizeInfo) { - throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, "can't know size of download, giving up"); } } @@ -660,7 +630,7 @@ public class DownloadThread extends Thread { * Check the HTTP response status and handle anything unusual (e.g. not 200/206). */ private void handleExceptionalStatus(State state, InnerState innerState, HttpResponse response) - throws StopRequest, RetryDownload { + throws StopRequestException, RetryDownload { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == 503 && mInfo.mNumFailed < Constants.MAX_RETRIES) { handleServiceUnavailable(state, response); @@ -669,6 +639,10 @@ public class DownloadThread extends Thread { handleRedirect(state, response, statusCode); } + if (Constants.LOGV) { + Log.i(Constants.TAG, "recevd_status = " + statusCode + + ", mContinuingDownload = " + innerState.mContinuingDownload); + } int expectedStatus = innerState.mContinuingDownload ? 206 : Downloads.Impl.STATUS_SUCCESS; if (statusCode != expectedStatus) { handleOtherStatus(state, innerState, statusCode); @@ -679,7 +653,7 @@ public class DownloadThread extends Thread { * Handle a status that we don't know how to deal with properly. */ private void handleOtherStatus(State state, InnerState innerState, int statusCode) - throws StopRequest { + throws StopRequestException { int finalStatus; if (Downloads.Impl.isStatusError(statusCode)) { finalStatus = statusCode; @@ -690,20 +664,21 @@ public class DownloadThread extends Thread { } else { finalStatus = Downloads.Impl.STATUS_UNHANDLED_HTTP_CODE; } - throw new StopRequest(finalStatus, "http error " + statusCode + ", mContinuingDownload: " + - innerState.mContinuingDownload); + throw new StopRequestException(finalStatus, "http error " + + statusCode + ", mContinuingDownload: " + innerState.mContinuingDownload); } /** * Handle a 3xx redirect status. */ private void handleRedirect(State state, HttpResponse response, int statusCode) - throws StopRequest, RetryDownload { + throws StopRequestException, RetryDownload { if (Constants.LOGVV) { Log.v(Constants.TAG, "got HTTP redirect " + statusCode); } if (state.mRedirectCount >= Constants.MAX_REDIRECTS) { - throw new StopRequest(Downloads.Impl.STATUS_TOO_MANY_REDIRECTS, "too many redirects"); + throw new StopRequestException(Downloads.Impl.STATUS_TOO_MANY_REDIRECTS, + "too many redirects"); } Header header = response.getFirstHeader("Location"); if (header == null) { @@ -721,7 +696,7 @@ public class DownloadThread extends Thread { Log.d(Constants.TAG, "Couldn't resolve redirect URI " + header.getValue() + " for " + mInfo.mUri); } - throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, "Couldn't resolve redirect URI"); } ++state.mRedirectCount; @@ -736,7 +711,8 @@ public class DownloadThread extends Thread { /** * Handle a 503 Service Unavailable status by processing the Retry-After header. */ - private void handleServiceUnavailable(State state, HttpResponse response) throws StopRequest { + private void handleServiceUnavailable(State state, HttpResponse response) + throws StopRequestException { if (Constants.LOGVV) { Log.v(Constants.TAG, "got HTTP response code 503"); } @@ -763,7 +739,7 @@ public class DownloadThread extends Thread { // ignored - retryAfter stays 0 in this case. } } - throw new StopRequest(Downloads.Impl.STATUS_WAITING_TO_RETRY, + throw new StopRequestException(Downloads.Impl.STATUS_WAITING_TO_RETRY, "got 503 Service Unavailable, will retry later"); } @@ -771,15 +747,15 @@ public class DownloadThread extends Thread { * Send the request to the server, handling any I/O exceptions. */ private HttpResponse sendRequest(State state, AndroidHttpClient client, HttpGet request) - throws StopRequest { + throws StopRequestException { try { return client.execute(request); } catch (IllegalArgumentException ex) { - throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, "while trying to execute request: " + ex.toString(), ex); } catch (IOException ex) { logNetworkState(); - throw new StopRequest(getFinalStatusForHttpError(state), + throw new StopRequestException(getFinalStatusForHttpError(state), "while trying to execute request: " + ex.toString(), ex); } } @@ -801,33 +777,49 @@ public class DownloadThread extends Thread { * appropriately for resumption. */ private void setupDestinationFile(State state, InnerState innerState) - throws StopRequest { + throws StopRequestException { if (!TextUtils.isEmpty(state.mFilename)) { // only true if we've already run a thread for this download - if (!Helpers.isFilenameValid(state.mFilename, - Helpers.getDownloadsDataDirectory(mContext))) { + if (Constants.LOGV) { + Log.i(Constants.TAG, "have run thread before for id: " + mInfo.mId + + ", and state.mFilename: " + state.mFilename); + } + if (!Helpers.isFilenameValid(state.mFilename, + mStorageManager.getDownloadDataDirectory())) { // this should never happen - throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, "found invalid internal destination filename"); } // We're resuming a download that got interrupted File f = new File(state.mFilename); if (f.exists()) { + if (Constants.LOGV) { + Log.i(Constants.TAG, "resuming download for id: " + mInfo.mId + + ", and state.mFilename: " + state.mFilename); + } long fileLength = f.length(); if (fileLength == 0) { // The download hadn't actually started, we can restart from scratch f.delete(); state.mFilename = null; + if (Constants.LOGV) { + Log.i(Constants.TAG, "resuming download for id: " + mInfo.mId + + ", BUT starting from scratch again: "); + } } else if (mInfo.mETag == null && !mInfo.mNoIntegrity) { // This should've been caught upon failure f.delete(); - throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME, + throw new StopRequestException(Downloads.Impl.STATUS_CANNOT_RESUME, "Trying to resume a download that can't be resumed"); } else { // All right, we'll be able to resume this download + if (Constants.LOGV) { + Log.i(Constants.TAG, "resuming download for id: " + mInfo.mId + + ", and starting with file of length: " + fileLength); + } try { state.mStream = new FileOutputStream(state.mFilename, true); } catch (FileNotFoundException exc) { - throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, "while opening destination for resuming: " + exc.toString(), exc); } innerState.mBytesSoFar = (int) fileLength; @@ -836,6 +828,10 @@ public class DownloadThread extends Thread { } innerState.mHeaderETag = mInfo.mETag; innerState.mContinuingDownload = true; + if (Constants.LOGV) { + Log.i(Constants.TAG, "resuming download for id: " + mInfo.mId + + ", and setting mContinuingDownload to true: "); + } } } } diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java index f392f3e9..a9c48beb 100644 --- a/src/com/android/providers/downloads/Helpers.java +++ b/src/com/android/providers/downloads/Helpers.java @@ -16,16 +16,13 @@ package com.android.providers.downloads; -import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.database.Cursor; import android.drm.mobile1.DrmRawContent; import android.net.Uri; import android.os.Environment; -import android.os.StatFs; import android.os.SystemClock; import android.provider.Downloads; import android.util.Config; @@ -42,7 +39,6 @@ import java.util.regex.Pattern; * Some helper functions for the download manager */ public class Helpers { - public static Random sRandom = new Random(SystemClock.uptimeMillis()); /** Regex used to parse content-disposition headers */ @@ -70,23 +66,10 @@ public class Helpers { return null; } - /** - * Exception thrown from methods called by generateSaveFile() for any fatal error. - */ - public static class GenerateSaveFileError extends Exception { - int mStatus; - String mMessage; - - public GenerateSaveFileError(int status, String message) { - mStatus = status; - mMessage = message; - } - } - /** * Creates a filename (where the file should be saved) from info about a download. */ - public static String generateSaveFile( + static String generateSaveFile( Context context, String url, String hint, @@ -95,64 +78,24 @@ public class Helpers { String mimeType, int destination, long contentLength, - boolean isPublicApi) throws GenerateSaveFileError { + boolean isPublicApi, StorageManager storageManager) throws StopRequestException { checkCanHandleDownload(context, mimeType, destination, isPublicApi); + String path; + File base = null; if (destination == Downloads.Impl.DESTINATION_FILE_URI) { - String path = verifyFileUri(context, hint, contentLength); - String c = getFullPath(path, mimeType, destination, null); - return c; + path = Uri.parse(hint).getPath(); } else { - return chooseFullPath(context, url, hint, contentDisposition, contentLocation, mimeType, - destination, contentLength); + base = storageManager.locateDestinationDirectory(mimeType, destination, + contentLength); + path = chooseFilename(url, hint, contentDisposition, contentLocation, + destination); } - } - - private static String verifyFileUri(Context context, String hint, long contentLength) - throws GenerateSaveFileError { - if (!isExternalMediaMounted()) { - throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR, - "external media not mounted"); - } - String path = Uri.parse(hint).getPath(); - if (getAvailableBytes(getFilesystemRoot(context, path)) < contentLength) { - throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, - "insufficient space on external storage"); - } - - return path; - } - - /** - * @return the root of the filesystem containing the given path - */ - static File getFilesystemRoot(Context context, String path) { - File cache = Environment.getDownloadCacheDirectory(); - if (path.startsWith(cache.getPath())) { - return cache; - } - File systemCache = Helpers.getDownloadsDataDirectory(context); - if (path.startsWith(systemCache.getPath())) { - return systemCache; - } - File external = Environment.getExternalStorageDirectory(); - if (path.startsWith(external.getPath())) { - return external; - } - throw new IllegalArgumentException("Cannot determine filesystem root for " + path); - } - - private static String chooseFullPath(Context context, String url, String hint, - String contentDisposition, String contentLocation, - String mimeType, int destination, long contentLength) - throws GenerateSaveFileError { - File base = locateDestinationDirectory(context, mimeType, destination, contentLength); - String filename = chooseFilename(url, hint, contentDisposition, contentLocation, - destination); - return getFullPath(filename, mimeType, destination, base); + storageManager.verifySpace(destination, path, contentLength); + return getFullPath(path, mimeType, destination, base); } private static String getFullPath(String filename, String mimeType, int destination, - File base) throws GenerateSaveFileError { + File base) throws StopRequestException { // Split filename between base and extension // Add an extension if filename does not have one String extension = null; @@ -178,7 +121,7 @@ public class Helpers { } private static void checkCanHandleDownload(Context context, String mimeType, int destination, - boolean isPublicApi) throws GenerateSaveFileError { + boolean isPublicApi) throws StopRequestException { if (isPublicApi) { return; } @@ -186,7 +129,7 @@ public class Helpers { if (destination == Downloads.Impl.DESTINATION_EXTERNAL || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) { if (mimeType == null) { - throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE, + throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE, "external download with no mime type not allowed"); } if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { @@ -211,92 +154,13 @@ public class Helpers { if (Constants.LOGV) { Log.v(Constants.TAG, "no handler found for type " + mimeType); } - throw new GenerateSaveFileError(Downloads.Impl.STATUS_NOT_ACCEPTABLE, + throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE, "no handler found for this download type"); } } } } - private static File locateDestinationDirectory(Context context, String mimeType, - int destination, long contentLength) - throws GenerateSaveFileError { - // DRM messages should be temporarily stored internally and then passed to - // the DRM content provider - if (destination == Downloads.Impl.DESTINATION_CACHE_PARTITION - || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE - || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING - || destination == Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION - || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { - return getCacheDestination(context, contentLength, destination); - } - - return getExternalDestination(contentLength); - } - - private static File getExternalDestination(long contentLength) throws GenerateSaveFileError { - if (!isExternalMediaMounted()) { - throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR, - "external media not mounted"); - } - - File root = Environment.getExternalStorageDirectory(); - if (getAvailableBytes(root) < contentLength) { - // Insufficient space. - Log.d(Constants.TAG, "download aborted - not enough free space"); - throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, - "insufficient space on external media"); - } - - File base = new File(root.getPath() + Constants.DEFAULT_DL_SUBDIR); - if (!base.isDirectory() && !base.mkdir()) { - // Can't create download directory, e.g. because a file called "download" - // already exists at the root level, or the SD card filesystem is read-only. - throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR, - "unable to create external downloads directory " + base.getPath()); - } - return base; - } - - public static boolean isExternalMediaMounted() { - if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { - // No SD card found. - Log.d(Constants.TAG, "no external storage"); - return false; - } - return true; - } - - private static File getCacheDestination(Context context, long contentLength, int destination) - throws GenerateSaveFileError { - File base; - base = (destination == Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) ? - Environment.getDownloadCacheDirectory() : - Helpers.getDownloadsDataDirectory(context); - long bytesAvailable = getAvailableBytes(base); - while (bytesAvailable < contentLength) { - // Insufficient space; try discarding purgeable files. - if (!discardPurgeableFiles(destination, context, contentLength - bytesAvailable)) { - // No files to purge, give up. - throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, - "not enough free space in internal download storage: " + base + - ", unable to free any more"); - } - bytesAvailable = getAvailableBytes(base); - } - return base; - } - - /** - * @return the number of bytes available on the filesystem rooted at the given File - */ - public static long getAvailableBytes(File root) { - StatFs stat = new StatFs(root.getPath()); - // put a bit of margin (in case creating the file grows the system by a few blocks) - long availableBlocks = (long) stat.getAvailableBlocks() - 4; - return stat.getBlockSize() * availableBlocks; - } - private static String chooseFilename(String url, String hint, String contentDisposition, String contentLocation, int destination) { String filename = null; @@ -445,7 +309,7 @@ public class Helpers { } private static String chooseUniqueFilename(int destination, String filename, - String extension, boolean recoveryDir) throws GenerateSaveFileError { + String extension, boolean recoveryDir) throws StopRequestException { String fullFilename = filename + extension; if (!new File(fullFilename).exists() && (!recoveryDir || @@ -483,61 +347,10 @@ public class Helpers { sequence += sRandom.nextInt(magnitude) + 1; } } - throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR, + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, "failed to generate an unused filename on internal download storage"); } - /** - * Deletes purgeable files from the cache partition. This also deletes - * the matching database entries. Files are deleted in LRU order until - * the total byte size is greater than targetBytes. - */ - static final boolean discardPurgeableFiles(int destination, Context context, - long targetBytes) { - String destStr = (destination == Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) ? - String.valueOf(destination) : - String.valueOf(Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE); - String[] bindArgs = new String[]{destStr}; - Cursor cursor = context.getContentResolver().query( - Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - null, - "( " + - Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " + - Downloads.Impl.COLUMN_DESTINATION + " = ? )", - bindArgs, - Downloads.Impl.COLUMN_LAST_MODIFICATION); - if (cursor == null) { - return false; - } - long totalFreed = 0; - try { - cursor.moveToFirst(); - while (!cursor.isAfterLast() && totalFreed < targetBytes) { - File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA))); - if (Constants.LOGVV) { - Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " + - file.length() + " bytes"); - } - totalFreed += file.length(); - file.delete(); - long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID)); - context.getContentResolver().delete( - ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id), - null, null); - cursor.moveToNext(); - } - } finally { - cursor.close(); - } - if (Constants.LOGV) { - if (totalFreed > 0) { - Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " + - targetBytes + " requested"); - } - } - return totalFreed > 0; - } - /** * Returns whether the network is available */ @@ -864,7 +677,4 @@ public class Helpers { } return sb.toString(); } - static final File getDownloadsDataDirectory(Context context) { - return context.getCacheDir(); - } } diff --git a/src/com/android/providers/downloads/StopRequestException.java b/src/com/android/providers/downloads/StopRequestException.java new file mode 100644 index 00000000..0ccf53cb --- /dev/null +++ b/src/com/android/providers/downloads/StopRequestException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2010 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; + +/** + * Raised to indicate that the current request should be stopped immediately. + * + * Note the message passed to this exception will be logged and therefore must be guaranteed + * not to contain any PII, meaning it generally can't include any information about the request + * URI, headers, or destination filename. + */ +class StopRequestException extends Exception { + public int mFinalStatus; + + public StopRequestException(int finalStatus, String message) { + super(message); + mFinalStatus = finalStatus; + } + + public StopRequestException(int finalStatus, String message, Throwable throwable) { + super(message, throwable); + mFinalStatus = finalStatus; + } +} diff --git a/src/com/android/providers/downloads/StorageManager.java b/src/com/android/providers/downloads/StorageManager.java new file mode 100644 index 00000000..d7d0a7ad --- /dev/null +++ b/src/com/android/providers/downloads/StorageManager.java @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2010 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.content.ContentUris; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.drm.mobile1.DrmRawContent; +import android.net.Uri; +import android.os.Environment; +import android.os.StatFs; +import android.provider.Downloads; +import android.util.Log; + +import com.android.internal.R; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Manages the storage space consumed by Downloads Data dir. When space falls below + * a threshold limit (set in resource xml files), starts cleanup of the Downloads data dir + * to free up space. + */ +class StorageManager { + /** the max amount of space allowed to be taken up by the downloads data dir */ + private static final long sMaxdownloadDataDirSize = + Resources.getSystem().getInteger(R.integer.config_downloadDataDirSize) * 1024 * 1024; + + /** threshold (in bytes) beyond which the low space warning kicks in and attempt is made to + * purge some downloaded files to make space + */ + private static final long sDownloadDataDirLowSpaceThreshold = + Resources.getSystem().getInteger( + R.integer.config_downloadDataDirLowSpaceThreshold) + * sMaxdownloadDataDirSize / 100; + + /** see {@link Environment#getExternalStorageDirectory()} */ + private final File mExternalStorageDir; + + /** see {@link Environment#getDownloadCacheDirectory()} */ + private final File mSystemCacheDir; + + /** The downloaded files are saved to this dir. it is the value returned by + * {@link Context#getCacheDir()}. + */ + private final File mDownloadDataDir; + + /** the Singleton instance of this class. + * TODO: once DownloadService is refactored into a long-living object, there is no need + * for this Singleton'ing. + */ + private static StorageManager sSingleton = null; + + /** how often do we need to perform checks on space to make sure space is available */ + private static final int FREQUENCY_OF_CHECKS_ON_SPACE_AVAILABILITY = 1024 * 1024; // 1MB + private int mBytesDownloadedSinceLastCheckOnSpace = 0; + + /** misc members */ + private final Context mContext; + + /** + * maintains Singleton instance of this class + */ + synchronized static StorageManager getInstance(Context context) { + if (sSingleton == null) { + sSingleton = new StorageManager(context); + } + return sSingleton; + } + + private StorageManager(Context context) { // constructor is private + mContext = context; + mDownloadDataDir = context.getCacheDir(); + mExternalStorageDir = Environment.getExternalStorageDirectory(); + mSystemCacheDir = Environment.getDownloadCacheDirectory(); + startThreadToCleanupDatabaseAndPurgeFileSystem(); + } + + /** How often should database and filesystem be cleaned up to remove spurious files + * from the file system and + * The value is specified in terms of num of downloads since last time the cleanup was done. + */ + private static final int FREQUENCY_OF_DATABASE_N_FILESYSTEM_CLEANUP = 250; + private int mNumDownloadsSoFar = 0; + + synchronized void incrementNumDownloadsSoFar() { + if (++mNumDownloadsSoFar % FREQUENCY_OF_DATABASE_N_FILESYSTEM_CLEANUP == 0) { + startThreadToCleanupDatabaseAndPurgeFileSystem(); + } + } + /* start a thread to cleanup the following + * remove spurious files from the file system + * remove excess entries from the database + */ + private Thread mCleanupThread = null; + private synchronized void startThreadToCleanupDatabaseAndPurgeFileSystem() { + if (mCleanupThread != null && mCleanupThread.isAlive()) { + return; + } + mCleanupThread = new Thread() { + @Override public void run() { + removeSpuriousFiles(); + trimDatabase(); + } + }; + mCleanupThread.start(); + } + + void verifySpaceBeforeWritingToFile(int destination, String path, long length) + throws StopRequestException { + // do this check only once for every 1MB of downloaded data + if (incrementBytesDownloadedSinceLastCheckOnSpace(length) < + FREQUENCY_OF_CHECKS_ON_SPACE_AVAILABILITY) { + return; + } + verifySpace(destination, path, length); + } + + void verifySpace(int destination, String path, long length) throws StopRequestException { + resetBytesDownloadedSinceLastCheckOnSpace(); + File dir = null; + switch (destination) { + case Downloads.Impl.DESTINATION_CACHE_PARTITION: + case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING: + case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE: + dir = mDownloadDataDir; + break; + case Downloads.Impl.DESTINATION_EXTERNAL: + dir = mExternalStorageDir; + break; + case Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION: + dir = mSystemCacheDir; + break; + case Downloads.Impl.DESTINATION_FILE_URI: + if (path.startsWith(mExternalStorageDir.getPath())) { + dir = mExternalStorageDir; + } else if (path.startsWith(mDownloadDataDir.getPath())) { + dir = mDownloadDataDir; + } else if (path.startsWith(mSystemCacheDir.getPath())) { + dir = mSystemCacheDir; + } + break; + } + if (dir == null) { + throw new IllegalStateException("invalid combination of destination: " + destination + + ", path: " + path); + } + findSpace(dir, length, destination); + } + + /** + * finds space in the given filesystem (input param: root) to accommodate # of bytes + * specified by the input param(targetBytes). + * returns true if found. false otherwise. + */ + private synchronized void findSpace(File root, long targetBytes, int destination) + throws StopRequestException { + if (targetBytes == 0) { + return; + } + if (destination == Downloads.Impl.DESTINATION_FILE_URI || + destination == Downloads.Impl.DESTINATION_EXTERNAL) { + if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + throw new StopRequestException(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR, + "external media not mounted"); + } + } + // is there enough space in the file system of the given param 'root'. + long bytesAvailable = getAvailableBytesInFileSystemAtGivenRoot(root); + if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) { + /* filesystem's available space is below threshold for low space warning. + * threshold typically is 10% of download data dir space quota. + * try to cleanup and see if the low space situation goes away. + */ + discardPurgeableFiles(destination, sDownloadDataDirLowSpaceThreshold); + removeSpuriousFiles(); + bytesAvailable = getAvailableBytesInFileSystemAtGivenRoot(root); + if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) { + /* + * available space is still below the threshold limit. + * + * If this is system cache dir, print a warning. + * otherwise, don't allow downloading until more space + * is available because downloadmanager shouldn't end up taking those last + * few MB of space left on the filesystem. + */ + if (root.equals(mSystemCacheDir)) { + Log.w(Constants.TAG, "System cache dir ('/cache') is running low on space." + + "space available (in bytes): " + bytesAvailable); + } else { + throw new StopRequestException(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, + "space in the filesystem rooted at: " + root + + " is below 10% availability. stopping this download."); + } + } + } + if (root.equals(mDownloadDataDir)) { + // this download is going into downloads data dir. check space in that specific dir. + bytesAvailable = getAvailableBytesInDownloadsDataDir(mSystemCacheDir); + if (bytesAvailable < sDownloadDataDirLowSpaceThreshold) { + // print a warning + Log.w(Constants.TAG, "Downloads data dir: " + root + + " is running low on space. space available (in b): " + bytesAvailable); + } else if (bytesAvailable < targetBytes) { + // Insufficient space; make space. + discardPurgeableFiles(destination, sDownloadDataDirLowSpaceThreshold); + removeSpuriousFiles(); + bytesAvailable = getAvailableBytesInDownloadsDataDir(mSystemCacheDir); + } + } + if (bytesAvailable < targetBytes) { + throw new StopRequestException(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR, + "not enough free space in the filesystem rooted at: " + root + + " and unable to free any more"); + } + } + + /** + * returns the number of bytes available in the downloads data dir + * TODO this implementation is too slow. optimize it. + */ + private long getAvailableBytesInDownloadsDataDir(File root) { + File[] files = root.listFiles(); + long space = sMaxdownloadDataDirSize; + if (files == null) { + return space; + } + int size = files.length; + for (int i = 0; i < size; i++) { + space -= files[i].length(); + } + if (Constants.LOGV) { + Log.i(Constants.TAG, "available space (in bytes) in downloads data dir: " + space); + } + return space; + } + + private long getAvailableBytesInFileSystemAtGivenRoot(File root) { + StatFs stat = new StatFs(root.getPath()); + // put a bit of margin (in case creating the file grows the system by a few blocks) + long availableBlocks = (long) stat.getAvailableBlocks() - 4; + long size = stat.getBlockSize() * availableBlocks; + if (Constants.LOGV) { + Log.i(Constants.TAG, "available space (in bytes) in filesystem rooted at: " + + root.getPath() + " is: " + size); + } + return size; + } + + File locateDestinationDirectory(String mimeType, int destination, long contentLength) + throws StopRequestException { + switch (destination) { + case Downloads.Impl.DESTINATION_CACHE_PARTITION: + case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE: + case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING: + return mDownloadDataDir; + case Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION: + return mSystemCacheDir; + case Downloads.Impl.DESTINATION_EXTERNAL: + File base = new File(mExternalStorageDir.getPath() + Constants.DEFAULT_DL_SUBDIR); + if (!base.isDirectory() && !base.mkdir()) { + // Can't create download directory, e.g. because a file called "download" + // already exists at the root level, or the SD card filesystem is read-only. + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, + "unable to create external downloads directory " + base.getPath()); + } + return base; + default: + // DRM messages should be temporarily stored internally and then passed to + // the DRM content provider + if (DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { + return mDownloadDataDir; + } + throw new IllegalStateException("unexpected value for destination: " + destination); + } + } + + File getDownloadDataDirectory() { + return mDownloadDataDir; + } + + /** + * Deletes purgeable files from the cache partition. This also deletes + * the matching database entries. Files are deleted in LRU order until + * the total byte size is greater than targetBytes + */ + private long discardPurgeableFiles(int destination, long targetBytes) { + if (Constants.LOGV) { + Log.i(Constants.TAG, "discardPurgeableFiles: destination = " + destination + + ", targetBytes = " + targetBytes); + } + String destStr = (destination == Downloads.Impl.DESTINATION_SYSTEMCACHE_PARTITION) ? + String.valueOf(destination) : + String.valueOf(Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE); + String[] bindArgs = new String[]{destStr}; + Cursor cursor = mContext.getContentResolver().query( + Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + null, + "( " + + Downloads.Impl.COLUMN_STATUS + " = '" + Downloads.Impl.STATUS_SUCCESS + "' AND " + + Downloads.Impl.COLUMN_DESTINATION + " = ? )", + bindArgs, + Downloads.Impl.COLUMN_LAST_MODIFICATION); + if (cursor == null) { + return 0; + } + long totalFreed = 0; + try { + while (cursor.moveToNext() && totalFreed < targetBytes) { + File file = new File(cursor.getString(cursor.getColumnIndex(Downloads.Impl._DATA))); + if (Constants.LOGV) { + Log.i(Constants.TAG, "purging " + file.getAbsolutePath() + " for " + + file.length() + " bytes"); + } + totalFreed += file.length(); + file.delete(); + long id = cursor.getLong(cursor.getColumnIndex(Downloads.Impl._ID)); + mContext.getContentResolver().delete( + ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id), + null, null); + } + } finally { + cursor.close(); + } + if (Constants.LOGV) { + Log.i(Constants.TAG, "Purged files, freed " + totalFreed + " for " + + targetBytes + " requested"); + } + return totalFreed; + } + + /** + * Removes files in the systemcache and downloads data dir without corresponding entries in + * the downloads database. + * This can occur if a delete is done on the database but the file is not removed from the + * filesystem (due to sudden death of the process, for example). + * This is not a very common occurrence. So, do this only once in a while. + */ + private void removeSpuriousFiles() { + if (Constants.LOGV) { + Log.i(Constants.TAG, "in removeSpuriousFiles"); + } + // get a list of all files in system cache dir and downloads data dir + List files = new ArrayList(); + files.addAll(Arrays.asList(mSystemCacheDir.listFiles())); + files.addAll(Arrays.asList(mDownloadDataDir.listFiles())); + if (files.size() == 0) { + return; + } + + Cursor cursor = mContext.getContentResolver().query( + Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + new String[] { Downloads.Impl._DATA }, null, null, null); + try { + if (cursor != null) { + while (cursor.moveToNext()) { + files.remove(cursor.getString(0)); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + // delete the files not found in the database + for (File file : files) { + if (file.getName().equals(Constants.KNOWN_SPURIOUS_FILENAME) || + file.getName().equalsIgnoreCase(Constants.RECOVERY_DIRECTORY)) { + continue; + } + if (Constants.LOGV) { + Log.i(Constants.TAG, "deleting spurious file " + file.getAbsolutePath()); + } + file.delete(); + } + } + + /** + * Drops old rows from the database to prevent it from growing too large + * TODO logic in this method needs to be optimized. maintain the number of downloads + * in memory - so that this method can limit the amount of data read. + */ + private void trimDatabase() { + if (Constants.LOGV) { + Log.i(Constants.TAG, "in trimDatabase"); + } + Cursor cursor = null; + try { + cursor = mContext.getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + new String[] { Downloads.Impl._ID }, + Downloads.Impl.COLUMN_STATUS + " >= '200'", null, + Downloads.Impl.COLUMN_LAST_MODIFICATION); + if (cursor == null) { + // This isn't good - if we can't do basic queries in our database, + // nothing's gonna work + Log.e(Constants.TAG, "null cursor in trimDatabase"); + return; + } + if (cursor.moveToFirst()) { + int numDelete = cursor.getCount() - Constants.MAX_DOWNLOADS; + int columnId = cursor.getColumnIndexOrThrow(Downloads.Impl._ID); + while (numDelete > 0) { + Uri downloadUri = ContentUris.withAppendedId( + Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, cursor.getLong(columnId)); + mContext.getContentResolver().delete(downloadUri, null, null); + if (!cursor.moveToNext()) { + break; + } + numDelete--; + } + } + } catch (SQLiteException e) { + // trimming the database raised an exception. alright, ignore the exception + // and return silently. trimming database is not exactly a critical operation + // and there is no need to propagate the exception. + Log.w(Constants.TAG, "trimDatabase failed with exception: " + e.getMessage()); + return; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private synchronized int incrementBytesDownloadedSinceLastCheckOnSpace(long val) { + mBytesDownloadedSinceLastCheckOnSpace += val; + return mBytesDownloadedSinceLastCheckOnSpace; + } + + private synchronized void resetBytesDownloadedSinceLastCheckOnSpace() { + mBytesDownloadedSinceLastCheckOnSpace = 0; + } +} diff --git a/tests/src/com/android/providers/downloads/DownloadManagerFunctionalTest.java b/tests/src/com/android/providers/downloads/DownloadManagerFunctionalTest.java index d7f47870..c3ac8904 100644 --- a/tests/src/com/android/providers/downloads/DownloadManagerFunctionalTest.java +++ b/tests/src/com/android/providers/downloads/DownloadManagerFunctionalTest.java @@ -65,7 +65,7 @@ public class DownloadManagerFunctionalTest extends AbstractDownloadManagerFuncti Integer.toString(Downloads.Impl.DESTINATION_CACHE_PARTITION)); runUntilStatus(downloadUri, Downloads.Impl.STATUS_SUCCESS); assertEquals(FILE_CONTENT, getDownloadContents(downloadUri)); - assertStartsWith(Helpers.getDownloadsDataDirectory(getContext()).getAbsolutePath(), + assertStartsWith(getContext().getCacheDir().getAbsolutePath(), getDownloadFilename(downloadUri)); } diff --git a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java index 96fbaabd..64c19530 100644 --- a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java +++ b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java @@ -25,7 +25,6 @@ import android.net.Uri; import android.os.Environment; import android.provider.Downloads; import android.test.suitebuilder.annotation.LargeTest; -import android.test.suitebuilder.annotation.Suppress; import tests.http.MockResponse; import tests.http.RecordedRequest; @@ -386,10 +385,6 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { runSimpleFailureTest(DownloadManager.ERROR_HTTP_DATA_ERROR); } - /** - * un-suppress this test once the bug 3286430 is fixed - */ - @Suppress public void testInsufficientSpace() throws Exception { // this would be better done by stubbing the system API to check available space, but in the // meantime, just use an absurdly large header value -- cgit v1.2.3