diff options
Diffstat (limited to 'src/com/android/providers/downloads')
9 files changed, 1792 insertions, 1187 deletions
diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java index 81895439..4380059b 100644 --- a/src/com/android/providers/downloads/DownloadInfo.java +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -16,10 +16,21 @@ package com.android.providers.downloads; +import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.DownloadManager; import android.net.Uri; import android.provider.Downloads; +import android.provider.Downloads.Impl; +import android.util.Log; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; /** * Stores information about an individual download. @@ -45,52 +56,124 @@ public class DownloadInfo { public String mCookies; public String mUserAgent; public String mReferer; - public int mTotalBytes; - public int mCurrentBytes; + public long mTotalBytes; + public long mCurrentBytes; public String mETag; public boolean mMediaScanned; + public boolean mIsPublicApi; + public int mAllowedNetworkTypes; + public boolean mAllowRoaming; + public String mTitle; + public String mDescription; + public String mPausedReason; public int mFuzz; public volatile boolean mHasActiveThread; - public DownloadInfo(int id, String uri, boolean noIntegrity, - String hint, String fileName, - String mimeType, int destination, int visibility, int control, - int status, int numFailed, int retryAfter, int redirectCount, long lastMod, - String pckg, String clazz, String extras, String cookies, - String userAgent, String referer, int totalBytes, int currentBytes, String eTag, - boolean mediaScanned) { - mId = id; - mUri = uri; - mNoIntegrity = noIntegrity; - mHint = hint; - mFileName = fileName; - mMimeType = mimeType; - mDestination = destination; - mVisibility = visibility; - mControl = control; - mStatus = status; - mNumFailed = numFailed; - mRetryAfter = retryAfter; - mRedirectCount = redirectCount; - mLastMod = lastMod; - mPackage = pckg; - mClass = clazz; - mExtras = extras; - mCookies = cookies; - mUserAgent = userAgent; - mReferer = referer; - mTotalBytes = totalBytes; - mCurrentBytes = currentBytes; - mETag = eTag; - mMediaScanned = mediaScanned; - mFuzz = Helpers.sRandom.nextInt(1001); + private Map<String, String> mRequestHeaders = new HashMap<String, String>(); + private SystemFacade mSystemFacade; + private Context mContext; + + public DownloadInfo(Context context, SystemFacade systemFacade, Cursor cursor) { + mContext = context; + mSystemFacade = systemFacade; + + int retryRedirect = + cursor.getInt(cursor.getColumnIndexOrThrow(Constants.RETRY_AFTER_X_REDIRECT_COUNT)); + mId = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl._ID)); + mUri = cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_URI)); + mNoIntegrity = cursor.getInt(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_NO_INTEGRITY)) == 1; + mHint = cursor.getString(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_FILE_NAME_HINT)); + mFileName = cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl._DATA)); + mMimeType = cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_MIME_TYPE)); + mDestination = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_DESTINATION)); + mVisibility = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_VISIBILITY)); + mControl = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_CONTROL)); + mStatus = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_STATUS)); + mNumFailed = cursor.getInt(cursor.getColumnIndexOrThrow(Constants.FAILED_CONNECTIONS)); + mRetryAfter = retryRedirect & 0xfffffff; + mRedirectCount = retryRedirect >> 28; + mLastMod = cursor.getLong(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_LAST_MODIFICATION)); + mPackage = cursor.getString(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE)); + mClass = cursor.getString(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_NOTIFICATION_CLASS)); + mExtras = cursor.getString(cursor.getColumnIndexOrThrow( + Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS)); + mCookies = + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_COOKIE_DATA)); + mUserAgent = + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_USER_AGENT)); + mReferer = cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_REFERER)); + mTotalBytes = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_TOTAL_BYTES)); + mCurrentBytes = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_CURRENT_BYTES)); + mETag = cursor.getString(cursor.getColumnIndexOrThrow(Constants.ETAG)); + mMediaScanned = cursor.getInt(cursor.getColumnIndexOrThrow(Constants.MEDIA_SCANNED)) == 1; + mIsPublicApi = cursor.getInt( + cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_IS_PUBLIC_API)) != 0; + mAllowedNetworkTypes = cursor.getInt( + cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES)); + mAllowRoaming = cursor.getInt( + cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_ALLOW_ROAMING)) != 0; + mTitle = cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_TITLE)); + mDescription = + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_DESCRIPTION)); + mFuzz = Helpers.sRandom.nextInt(1001); + + readRequestHeaders(mId); + } + + private void readRequestHeaders(long downloadId) { + Uri headerUri = Downloads.Impl.CONTENT_URI.buildUpon() + .appendPath(Long.toString(downloadId)) + .appendPath(Downloads.Impl.RequestHeaders.URI_SEGMENT).build(); + Cursor cursor = mContext.getContentResolver().query(headerUri, null, null, null, null); + try { + int headerIndex = + cursor.getColumnIndexOrThrow(Downloads.Impl.RequestHeaders.COLUMN_HEADER); + int valueIndex = + cursor.getColumnIndexOrThrow(Downloads.Impl.RequestHeaders.COLUMN_VALUE); + for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { + mRequestHeaders.put(cursor.getString(headerIndex), cursor.getString(valueIndex)); + } + } finally { + cursor.close(); + } + + if (mCookies != null) { + mRequestHeaders.put("Cookie", mCookies); + } + if (mReferer != null) { + mRequestHeaders.put("Referer", mReferer); + } } - public void sendIntentIfRequested(Uri contentUri, Context context) { - if (mPackage != null && mClass != null) { - Intent intent = new Intent(Downloads.Impl.ACTION_DOWNLOAD_COMPLETED); + public Map<String, String> getHeaders() { + return Collections.unmodifiableMap(mRequestHeaders); + } + + public void sendIntentIfRequested(Uri contentUri) { + if (mPackage == null) { + return; + } + + Intent intent; + if (mIsPublicApi) { + intent = new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE); + intent.setPackage(mPackage); + intent.putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, (long) mId); + } else { // legacy behavior + if (mClass == null) { + return; + } + intent = new Intent(Downloads.Impl.ACTION_DOWNLOAD_COMPLETED); intent.setClassName(mPackage, mClass); if (mExtras != null) { intent.putExtra(Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS, mExtras); @@ -99,8 +182,8 @@ public class DownloadInfo { // applications would have an easier time spoofing download results by // sending spoofed intents. intent.setData(contentUri); - context.sendBroadcast(intent); } + mSystemFacade.sendBroadcast(intent); } /** @@ -175,7 +258,7 @@ public class DownloadInfo { if (mStatus == Downloads.Impl.STATUS_RUNNING_PAUSED) { if (mNumFailed == 0) { // download is waiting for network connectivity to return before it can resume - return true; + return canUseNetwork(); } if (restartTime() < now) { // download was waiting for a delayed restart, and the delay has expired @@ -202,14 +285,107 @@ public class DownloadInfo { /** * Returns whether this download is allowed to use the network. */ - public boolean canUseNetwork(boolean available, boolean roaming) { - if (!available) { + public boolean canUseNetwork() { + Integer networkType = mSystemFacade.getActiveNetworkType(); + if (networkType == null) { return false; } - if (mDestination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING) { - return !roaming; - } else { - return true; + if (!isNetworkTypeAllowed(networkType)) { + return false; + } + if (!isRoamingAllowed() && mSystemFacade.isNetworkRoaming()) { + return false; + } + return true; + } + + private boolean isRoamingAllowed() { + if (mIsPublicApi) { + return mAllowRoaming; + } else { // legacy behavior + return mDestination != Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING; + } + } + + /** + * Check if this download can proceed over the given network type. + * @param networkType a constant from ConnectivityManager.TYPE_*. + */ + private boolean isNetworkTypeAllowed(int networkType) { + if (mIsPublicApi) { + int flag = translateNetworkTypeToApiFlag(networkType); + if ((flag & mAllowedNetworkTypes) == 0) { + return false; + } + } + if (!isSizeAllowedForNetwork(networkType)) { + mPausedReason = mContext.getResources().getString( + R.string.notification_need_wifi_for_size); + return false; + } + return true; + } + + /** + * Translate a ConnectivityManager.TYPE_* constant to the corresponding + * DownloadManager.Request.NETWORK_* bit flag. + */ + private int translateNetworkTypeToApiFlag(int networkType) { + switch (networkType) { + case ConnectivityManager.TYPE_MOBILE: + return DownloadManager.Request.NETWORK_MOBILE; + + case ConnectivityManager.TYPE_WIFI: + return DownloadManager.Request.NETWORK_WIFI; + + case ConnectivityManager.TYPE_WIMAX: + return DownloadManager.Request.NETWORK_WIMAX; + + default: + return 0; } } + + /** + * Check if the download's size prohibits it from running over the current network. + */ + private boolean isSizeAllowedForNetwork(int networkType) { + if (mTotalBytes <= 0) { + return true; // we don't know the size yet + } + if (networkType == ConnectivityManager.TYPE_WIFI) { + return true; // anything goes over wifi + } + Long maxBytesOverMobile = mSystemFacade.getMaxBytesOverMobile(); + if (maxBytesOverMobile == null) { + return true; // no limit + } + return mTotalBytes <= maxBytesOverMobile; + } + + void start(long now) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "Service spawning thread to handle download " + mId); + } + if (mHasActiveThread) { + throw new IllegalStateException("Multiple threads on same download"); + } + if (mStatus != Impl.STATUS_RUNNING) { + mStatus = Impl.STATUS_RUNNING; + ContentValues values = new ContentValues(); + values.put(Impl.COLUMN_STATUS, mStatus); + mContext.getContentResolver().update( + ContentUris.withAppendedId(Impl.CONTENT_URI, mId), + values, null, null); + } + DownloadThread downloader = new DownloadThread(mContext, mSystemFacade, this); + mHasActiveThread = true; + mSystemFacade.startThread(downloader); + } + + public boolean isOnCache() { + return (mDestination == Downloads.Impl.DESTINATION_CACHE_PARTITION + || mDestination == Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING + || mDestination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE); + } } diff --git a/src/com/android/providers/downloads/DownloadNotification.java b/src/com/android/providers/downloads/DownloadNotification.java index e9c0d4e6..2c30644b 100644 --- a/src/com/android/providers/downloads/DownloadNotification.java +++ b/src/com/android/providers/downloads/DownloadNotification.java @@ -17,32 +17,33 @@ package com.android.providers.downloads; import android.app.Notification; -import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.net.Uri; import android.provider.Downloads; +import android.util.Log; +import android.view.View; import android.widget.RemoteViews; import java.util.HashMap; +import java.util.List; /** - * This class handles the updating of the Notification Manager for the + * This class handles the updating of the Notification Manager for the * cases where there is an ongoing download. Once the download is complete - * (be it successful or unsuccessful) it is no longer the responsibility + * (be it successful or unsuccessful) it is no longer the responsibility * of this component to show the download in the notification manager. * */ class DownloadNotification { Context mContext; - public NotificationManager mNotificationMgr; HashMap <String, NotificationItem> mNotifications; - + private SystemFacade mSystemFacade; + static final String LOGTAG = "DownloadNotification"; - static final String WHERE_RUNNING = + static final String WHERE_RUNNING = "(" + Downloads.Impl.COLUMN_STATUS + " >= '100') AND (" + Downloads.Impl.COLUMN_STATUS + " <= '199') AND (" + Downloads.Impl.COLUMN_VISIBILITY + " IS NULL OR " + @@ -53,8 +54,8 @@ class DownloadNotification { Downloads.Impl.COLUMN_STATUS + " >= '200' AND " + Downloads.Impl.COLUMN_VISIBILITY + " == '" + Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + "'"; - - + + /** * This inner class is used to collate downloads that are owned by * the same application. This is so that only one notification line @@ -63,17 +64,18 @@ class DownloadNotification { */ static class NotificationItem { int mId; // This first db _id for the download for the app - int mTotalCurrent = 0; - int mTotalTotal = 0; + long mTotalCurrent = 0; + long mTotalTotal = 0; int mTitleCount = 0; String mPackageName; // App package name String mDescription; String[] mTitles = new String[2]; // download titles. - + String mPausedText = null; + /* * Add a second download to this notification item. */ - void addItem(String title, int currentBytes, int totalBytes) { + void addItem(String title, long currentBytes, long totalBytes) { mTotalCurrent += currentBytes; if (totalBytes <= 0 || mTotalTotal == -1) { mTotalTotal = -1; @@ -86,92 +88,76 @@ class DownloadNotification { mTitleCount++; } } - - + + /** * Constructor - * @param ctx The context to use to obtain access to the + * @param ctx The context to use to obtain access to the * Notification Service */ - DownloadNotification(Context ctx) { + DownloadNotification(Context ctx, SystemFacade systemFacade) { mContext = ctx; - mNotificationMgr = (NotificationManager) mContext - .getSystemService(Context.NOTIFICATION_SERVICE); + mSystemFacade = systemFacade; mNotifications = new HashMap<String, NotificationItem>(); } - + /* - * Update the notification ui. + * Update the notification ui. */ - public void updateNotification() { - updateActiveNotification(); - updateCompletedNotification(); + public void updateNotification(List<DownloadInfo> downloads) { + updateActiveNotification(downloads); + updateCompletedNotification(downloads); } - private void updateActiveNotification() { - // Active downloads - Cursor c = mContext.getContentResolver().query( - Downloads.Impl.CONTENT_URI, new String [] { - Downloads.Impl._ID, - Downloads.Impl.COLUMN_TITLE, - Downloads.Impl.COLUMN_DESCRIPTION, - Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE, - Downloads.Impl.COLUMN_NOTIFICATION_CLASS, - Downloads.Impl.COLUMN_CURRENT_BYTES, - Downloads.Impl.COLUMN_TOTAL_BYTES, - Downloads.Impl.COLUMN_STATUS - }, - WHERE_RUNNING, null, Downloads.Impl._ID); - - if (c == null) { - return; - } - - // Columns match projection in query above - final int idColumn = 0; - final int titleColumn = 1; - final int descColumn = 2; - final int ownerColumn = 3; - final int classOwnerColumn = 4; - final int currentBytesColumn = 5; - final int totalBytesColumn = 6; - final int statusColumn = 7; - + private void updateActiveNotification(List<DownloadInfo> downloads) { // Collate the notifications mNotifications.clear(); - for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { - String packageName = c.getString(ownerColumn); - int max = c.getInt(totalBytesColumn); - int progress = c.getInt(currentBytesColumn); - long id = c.getLong(idColumn); - String title = c.getString(titleColumn); + for (DownloadInfo download : downloads) { + if (!isActiveAndVisible(download)) { + continue; + } + String packageName = download.mPackage; + long max = download.mTotalBytes; + long progress = download.mCurrentBytes; + long id = download.mId; + String title = download.mTitle; if (title == null || title.length() == 0) { title = mContext.getResources().getString( R.string.download_unknown_title); } + + NotificationItem item; if (mNotifications.containsKey(packageName)) { - mNotifications.get(packageName).addItem(title, progress, max); + item = mNotifications.get(packageName); + item.addItem(title, progress, max); } else { - NotificationItem item = new NotificationItem(); + item = new NotificationItem(); item.mId = (int) id; item.mPackageName = packageName; - item.mDescription = c.getString(descColumn); - String className = c.getString(classOwnerColumn); + item.mDescription = download.mDescription; + String className = download.mClass; item.addItem(title, progress, max); mNotifications.put(packageName, item); } - + if (hasPausedReason(download) && item.mPausedText == null) { + item.mPausedText = download.mPausedReason; + } } - c.close(); - + // Add the notifications for (NotificationItem item : mNotifications.values()) { // Build the notification object Notification n = new Notification(); - n.icon = android.R.drawable.stat_sys_download; + + boolean hasPausedText = (item.mPausedText != null); + int iconResource = android.R.drawable.stat_sys_download; + if (hasPausedText) { + iconResource = android.R.drawable.stat_sys_warning; + } + n.icon = iconResource; n.flags |= Notification.FLAG_ONGOING_EVENT; - + // Build the RemoteView object RemoteViews expandedView = new RemoteViews( "com.android.providers.downloads", @@ -186,18 +172,24 @@ class DownloadNotification { new Object[] { Integer.valueOf(item.mTitleCount - 2) })); } } else { - expandedView.setTextViewText(R.id.description, + expandedView.setTextViewText(R.id.description, item.mDescription); } expandedView.setTextViewText(R.id.title, title); - expandedView.setProgressBar(R.id.progress_bar, - item.mTotalTotal, - item.mTotalCurrent, - item.mTotalTotal == -1); - expandedView.setTextViewText(R.id.progress_text, + + if (hasPausedText) { + expandedView.setViewVisibility(R.id.progress_bar, View.GONE); + expandedView.setTextViewText(R.id.paused_text, item.mPausedText); + } else { + expandedView.setViewVisibility(R.id.paused_text, View.GONE); + expandedView.setProgressBar(R.id.progress_bar, + (int) item.mTotalTotal, + (int) item.mTotalCurrent, + item.mTotalTotal == -1); + } + expandedView.setTextViewText(R.id.progress_text, getDownloadingText(item.mTotalTotal, item.mTotalCurrent)); - expandedView.setImageViewResource(R.id.appIcon, - android.R.drawable.stat_sys_download); + expandedView.setImageViewResource(R.id.appIcon, iconResource); n.contentView = expandedView; Intent intent = new Intent(Constants.ACTION_LIST); @@ -208,51 +200,26 @@ class DownloadNotification { n.contentIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0); - mNotificationMgr.notify(item.mId, n); - + mSystemFacade.postNotification(item.mId, n); + } } - private void updateCompletedNotification() { - // Completed downloads - Cursor c = mContext.getContentResolver().query( - Downloads.Impl.CONTENT_URI, new String [] { - Downloads.Impl._ID, - Downloads.Impl.COLUMN_TITLE, - Downloads.Impl.COLUMN_DESCRIPTION, - Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE, - Downloads.Impl.COLUMN_NOTIFICATION_CLASS, - Downloads.Impl.COLUMN_CURRENT_BYTES, - Downloads.Impl.COLUMN_TOTAL_BYTES, - Downloads.Impl.COLUMN_STATUS, - Downloads.Impl.COLUMN_LAST_MODIFICATION, - Downloads.Impl.COLUMN_DESTINATION - }, - WHERE_COMPLETED, null, Downloads.Impl._ID); - - if (c == null) { - return; - } - - // Columns match projection in query above - final int idColumn = 0; - final int titleColumn = 1; - final int descColumn = 2; - final int ownerColumn = 3; - final int classOwnerColumn = 4; - final int currentBytesColumn = 5; - final int totalBytesColumn = 6; - final int statusColumn = 7; - final int lastModColumnId = 8; - final int destinationColumnId = 9; - - for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + private boolean hasPausedReason(DownloadInfo download) { + return download.mStatus == Downloads.STATUS_RUNNING_PAUSED && download.mPausedReason != null; + } + + private void updateCompletedNotification(List<DownloadInfo> downloads) { + for (DownloadInfo download : downloads) { + if (!isCompleteAndVisible(download)) { + return; + } // Add the notifications Notification n = new Notification(); n.icon = android.R.drawable.stat_sys_download_done; - long id = c.getLong(idColumn); - String title = c.getString(titleColumn); + long id = download.mId; + String title = download.mTitle; if (title == null || title.length() == 0) { title = mContext.getResources().getString( R.string.download_unknown_title); @@ -260,14 +227,14 @@ class DownloadNotification { Uri contentUri = Uri.parse(Downloads.Impl.CONTENT_URI + "/" + id); String caption; Intent intent; - if (Downloads.Impl.isStatusError(c.getInt(statusColumn))) { + if (Downloads.Impl.isStatusError(download.mStatus)) { caption = mContext.getResources() .getString(R.string.notification_download_failed); intent = new Intent(Constants.ACTION_LIST); } else { caption = mContext.getResources() .getString(R.string.notification_download_complete); - if (c.getInt(destinationColumnId) == Downloads.Impl.DESTINATION_EXTERNAL) { + if (download.mDestination == Downloads.Impl.DESTINATION_EXTERNAL) { intent = new Intent(Constants.ACTION_OPEN); } else { intent = new Intent(Constants.ACTION_LIST); @@ -277,7 +244,7 @@ class DownloadNotification { DownloadReceiver.class.getName()); intent.setData(contentUri); - n.when = c.getLong(lastModColumnId); + n.when = download.mLastMod; n.setLatestEventInfo(mContext, title, caption, PendingIntent.getBroadcast(mContext, 0, intent, 0)); @@ -287,9 +254,18 @@ class DownloadNotification { intent.setData(contentUri); n.deleteIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0); - mNotificationMgr.notify(c.getInt(idColumn), n); + mSystemFacade.postNotification(download.mId, n); } - c.close(); + } + + private boolean isActiveAndVisible(DownloadInfo download) { + return 100 <= download.mStatus && download.mStatus < 200 + && download.mVisibility != Downloads.VISIBILITY_HIDDEN; + } + + private boolean isCompleteAndVisible(DownloadInfo download) { + return download.mStatus >= 200 + && download.mVisibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED; } /* @@ -305,5 +281,5 @@ class DownloadNotification { sb.append('%'); return sb.toString(); } - + } diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java index d7c24f9a..f6b091b1 100644 --- a/src/com/android/providers/downloads/DownloadProvider.java +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -34,15 +34,20 @@ import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.Binder; +import android.os.Environment; import android.os.ParcelFileDescriptor; import android.os.Process; import android.provider.Downloads; import android.util.Config; import android.util.Log; +import com.google.common.annotations.VisibleForTesting; + import java.io.File; import java.io.FileNotFoundException; import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; /** @@ -53,11 +58,7 @@ public final class DownloadProvider extends ContentProvider { /** Database filename */ private static final String DB_NAME = "downloads.db"; /** Current database version */ - private static final int DB_VERSION = 100; - /** Database version from which upgrading is a nop */ - private static final int DB_VERSION_NOP_UPGRADE_FROM = 31; - /** Database version to which upgrading is a nop */ - private static final int DB_VERSION_NOP_UPGRADE_TO = 100; + private static final int DB_VERSION = 102; /** Name of table in the database */ private static final String DB_TABLE = "downloads"; @@ -72,9 +73,13 @@ public final class DownloadProvider extends ContentProvider { private static final int DOWNLOADS = 1; /** URI matcher constant for the URI of an individual download */ private static final int DOWNLOADS_ID = 2; + /** URI matcher constant for the URI of a download's request headers */ + private static final int REQUEST_HEADERS_URI = 3; static { sURIMatcher.addURI("downloads", "download", DOWNLOADS); sURIMatcher.addURI("downloads", "download/#", DOWNLOADS_ID); + sURIMatcher.addURI("downloads", "download/#/" + Downloads.Impl.RequestHeaders.URI_SEGMENT, + REQUEST_HEADERS_URI); } private static final String[] sAppReadableColumnsArray = new String[] { @@ -92,7 +97,8 @@ public final class DownloadProvider extends ContentProvider { Downloads.Impl.COLUMN_TOTAL_BYTES, Downloads.Impl.COLUMN_CURRENT_BYTES, Downloads.Impl.COLUMN_TITLE, - Downloads.Impl.COLUMN_DESCRIPTION + Downloads.Impl.COLUMN_DESCRIPTION, + Downloads.Impl.COLUMN_URI, }; private static HashSet<String> sAppReadableColumnsSet; @@ -110,6 +116,9 @@ public final class DownloadProvider extends ContentProvider { private int mSystemUid = -1; private int mDefContainerUid = -1; + @VisibleForTesting + SystemFacade mSystemFacade; + /** * Creates and updated database on demand when opening it. * Helper class to create database the first time the provider is @@ -117,7 +126,6 @@ public final class DownloadProvider extends ContentProvider { * an updated version of the database. */ private final class DatabaseHelper extends SQLiteOpenHelper { - public DatabaseHelper(final Context context) { super(context, DB_NAME, null, DB_VERSION); } @@ -130,40 +138,130 @@ public final class DownloadProvider extends ContentProvider { if (Constants.LOGVV) { Log.v(Constants.TAG, "populating new database"); } - createTable(db); + onUpgrade(db, 0, DB_VERSION); } - /* (not a javadoc comment) - * Checks data integrity when opening the database. - */ - /* - * @Override - * public void onOpen(final SQLiteDatabase db) { - * super.onOpen(db); - * } - */ - /** * Updates the database format when a content provider is used * with a database that was created with a different format. + * + * Note: to support downgrades, creating a table should always drop it first if it already + * exists. */ - // Note: technically, this could also be a downgrade, so if we want - // to gracefully handle upgrades we should be careful about - // what to do on downgrades. @Override public void onUpgrade(final SQLiteDatabase db, int oldV, final int newV) { - if (oldV == DB_VERSION_NOP_UPGRADE_FROM) { - if (newV == DB_VERSION_NOP_UPGRADE_TO) { // that's a no-op upgrade. - return; - } - // NOP_FROM and NOP_TO are identical, just in different codelines. Upgrading - // from NOP_FROM is the same as upgrading from NOP_TO. - oldV = DB_VERSION_NOP_UPGRADE_TO; + if (oldV == 31) { + // 31 and 100 are identical, just in different codelines. Upgrading from 31 is the + // same as upgrading from 100. + oldV = 100; + } else if (oldV < 100) { + // no logic to upgrade from these older version, just recreate the DB + Log.i(Constants.TAG, "Upgrading downloads database from version " + oldV + + " to version " + newV + ", which will destroy all old data"); + oldV = 99; + } else if (oldV > newV) { + // user must have downgraded software; we have no way to know how to downgrade the + // DB, so just recreate it + Log.i(Constants.TAG, "Downgrading downloads database from version " + oldV + + " (current version is " + newV + "), destroying all old data"); + oldV = 99; + } + + for (int version = oldV + 1; version <= newV; version++) { + upgradeTo(db, version); } - Log.i(Constants.TAG, "Upgrading downloads database from version " + oldV + " to " + newV - + ", which will destroy all old data"); - dropTable(db); - createTable(db); + } + + /** + * Upgrade database from (version - 1) to version. + */ + private void upgradeTo(SQLiteDatabase db, int version) { + switch (version) { + case 100: + createDownloadsTable(db); + break; + + case 101: + createHeadersTable(db); + break; + + case 102: + addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_IS_PUBLIC_API, + "INTEGER NOT NULL DEFAULT 0"); + addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_ALLOW_ROAMING, + "INTEGER NOT NULL DEFAULT 0"); + addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES, + "INTEGER NOT NULL DEFAULT 0"); + break; + + default: + throw new IllegalStateException("Don't know how to upgrade to " + version); + } + } + + /** + * Add a column to a table using ALTER TABLE. + * @param dbTable name of the table + * @param columnName name of the column to add + * @param columnDefinition SQL for the column definition + */ + private void addColumn(SQLiteDatabase db, String dbTable, String columnName, + String columnDefinition) { + db.execSQL("ALTER TABLE " + dbTable + " ADD COLUMN " + columnName + " " + + columnDefinition); + } + + /** + * Creates the table that'll hold the download information. + */ + private void createDownloadsTable(SQLiteDatabase db) { + try { + db.execSQL("DROP TABLE IF EXISTS " + DB_TABLE); + db.execSQL("CREATE TABLE " + DB_TABLE + "(" + + Downloads.Impl._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Downloads.Impl.COLUMN_URI + " TEXT, " + + Constants.RETRY_AFTER_X_REDIRECT_COUNT + " INTEGER, " + + Downloads.Impl.COLUMN_APP_DATA + " TEXT, " + + Downloads.Impl.COLUMN_NO_INTEGRITY + " BOOLEAN, " + + Downloads.Impl.COLUMN_FILE_NAME_HINT + " TEXT, " + + Constants.OTA_UPDATE + " BOOLEAN, " + + Downloads.Impl._DATA + " TEXT, " + + Downloads.Impl.COLUMN_MIME_TYPE + " TEXT, " + + Downloads.Impl.COLUMN_DESTINATION + " INTEGER, " + + Constants.NO_SYSTEM_FILES + " BOOLEAN, " + + Downloads.Impl.COLUMN_VISIBILITY + " INTEGER, " + + Downloads.Impl.COLUMN_CONTROL + " INTEGER, " + + Downloads.Impl.COLUMN_STATUS + " INTEGER, " + + Constants.FAILED_CONNECTIONS + " INTEGER, " + + Downloads.Impl.COLUMN_LAST_MODIFICATION + " BIGINT, " + + Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE + " TEXT, " + + Downloads.Impl.COLUMN_NOTIFICATION_CLASS + " TEXT, " + + Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS + " TEXT, " + + Downloads.Impl.COLUMN_COOKIE_DATA + " TEXT, " + + Downloads.Impl.COLUMN_USER_AGENT + " TEXT, " + + Downloads.Impl.COLUMN_REFERER + " TEXT, " + + Downloads.Impl.COLUMN_TOTAL_BYTES + " INTEGER, " + + Downloads.Impl.COLUMN_CURRENT_BYTES + " INTEGER, " + + Constants.ETAG + " TEXT, " + + Constants.UID + " INTEGER, " + + Downloads.Impl.COLUMN_OTHER_UID + " INTEGER, " + + Downloads.Impl.COLUMN_TITLE + " TEXT, " + + Downloads.Impl.COLUMN_DESCRIPTION + " TEXT, " + + Constants.MEDIA_SCANNED + " BOOLEAN);"); + } catch (SQLException ex) { + Log.e(Constants.TAG, "couldn't create table in downloads database"); + throw ex; + } + } + + private void createHeadersTable(SQLiteDatabase db) { + db.execSQL("DROP TABLE IF EXISTS " + Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE); + db.execSQL("CREATE TABLE " + Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE + "(" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID + " INTEGER NOT NULL," + + Downloads.Impl.RequestHeaders.COLUMN_HEADER + " TEXT NOT NULL," + + Downloads.Impl.RequestHeaders.COLUMN_VALUE + " TEXT NOT NULL" + + ");"); } } @@ -172,6 +270,10 @@ public final class DownloadProvider extends ContentProvider { */ @Override public boolean onCreate() { + if (mSystemFacade == null) { + mSystemFacade = new RealSystemFacade(getContext()); + } + mOpenHelper = new DatabaseHelper(getContext()); // Initialize the system uid mSystemUid = Process.SYSTEM_UID; @@ -215,64 +317,11 @@ public final class DownloadProvider extends ContentProvider { } /** - * Creates the table that'll hold the download information. - */ - private void createTable(SQLiteDatabase db) { - try { - db.execSQL("CREATE TABLE " + DB_TABLE + "(" + - Downloads.Impl._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + - Downloads.Impl.COLUMN_URI + " TEXT, " + - Constants.RETRY_AFTER_X_REDIRECT_COUNT + " INTEGER, " + - Downloads.Impl.COLUMN_APP_DATA + " TEXT, " + - Downloads.Impl.COLUMN_NO_INTEGRITY + " BOOLEAN, " + - Downloads.Impl.COLUMN_FILE_NAME_HINT + " TEXT, " + - Constants.OTA_UPDATE + " BOOLEAN, " + - Downloads.Impl._DATA + " TEXT, " + - Downloads.Impl.COLUMN_MIME_TYPE + " TEXT, " + - Downloads.Impl.COLUMN_DESTINATION + " INTEGER, " + - Constants.NO_SYSTEM_FILES + " BOOLEAN, " + - Downloads.Impl.COLUMN_VISIBILITY + " INTEGER, " + - Downloads.Impl.COLUMN_CONTROL + " INTEGER, " + - Downloads.Impl.COLUMN_STATUS + " INTEGER, " + - Constants.FAILED_CONNECTIONS + " INTEGER, " + - Downloads.Impl.COLUMN_LAST_MODIFICATION + " BIGINT, " + - Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE + " TEXT, " + - Downloads.Impl.COLUMN_NOTIFICATION_CLASS + " TEXT, " + - Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS + " TEXT, " + - Downloads.Impl.COLUMN_COOKIE_DATA + " TEXT, " + - Downloads.Impl.COLUMN_USER_AGENT + " TEXT, " + - Downloads.Impl.COLUMN_REFERER + " TEXT, " + - Downloads.Impl.COLUMN_TOTAL_BYTES + " INTEGER, " + - Downloads.Impl.COLUMN_CURRENT_BYTES + " INTEGER, " + - Constants.ETAG + " TEXT, " + - Constants.UID + " INTEGER, " + - Downloads.Impl.COLUMN_OTHER_UID + " INTEGER, " + - Downloads.Impl.COLUMN_TITLE + " TEXT, " + - Downloads.Impl.COLUMN_DESCRIPTION + " TEXT, " + - Constants.MEDIA_SCANNED + " BOOLEAN);"); - } catch (SQLException ex) { - Log.e(Constants.TAG, "couldn't create table in downloads database"); - throw ex; - } - } - - /** - * Deletes the table that holds the download information. - */ - private void dropTable(SQLiteDatabase db) { - try { - db.execSQL("DROP TABLE IF EXISTS " + DB_TABLE); - } catch (SQLException ex) { - Log.e(Constants.TAG, "couldn't drop table in downloads database"); - throw ex; - } - } - - /** * Inserts a row in the database */ @Override public Uri insert(final Uri uri, final ContentValues values) { + checkInsertPermissions(values); SQLiteDatabase db = mOpenHelper.getWritableDatabase(); if (sURIMatcher.match(uri) != DOWNLOADS) { @@ -289,14 +338,37 @@ public final class DownloadProvider extends ContentProvider { copyBoolean(Downloads.Impl.COLUMN_NO_INTEGRITY, values, filteredValues); copyString(Downloads.Impl.COLUMN_FILE_NAME_HINT, values, filteredValues); copyString(Downloads.Impl.COLUMN_MIME_TYPE, values, filteredValues); + + copyBoolean(Downloads.Impl.COLUMN_IS_PUBLIC_API, values, filteredValues); + boolean isPublicApi = + values.getAsBoolean(Downloads.Impl.COLUMN_IS_PUBLIC_API) == Boolean.TRUE; + Integer dest = values.getAsInteger(Downloads.Impl.COLUMN_DESTINATION); if (dest != null) { if (getContext().checkCallingPermission(Downloads.Impl.PERMISSION_ACCESS_ADVANCED) != PackageManager.PERMISSION_GRANTED && dest != Downloads.Impl.DESTINATION_EXTERNAL - && dest != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) { + && dest != Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE + && dest != Downloads.Impl.DESTINATION_FILE_URI) { throw new SecurityException("unauthorized destination code"); } + // for public API behavior, if an app has CACHE_NON_PURGEABLE permission, automatically + // switch to non-purgeable download + boolean hasNonPurgeablePermission = + getContext().checkCallingPermission( + Downloads.Impl.PERMISSION_CACHE_NON_PURGEABLE) + == PackageManager.PERMISSION_GRANTED; + if (isPublicApi && dest == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE + && hasNonPurgeablePermission) { + dest = Downloads.Impl.DESTINATION_CACHE_PARTITION; + } + if (dest == Downloads.Impl.DESTINATION_FILE_URI) { + getContext().enforcePermission( + android.Manifest.permission.WRITE_EXTERNAL_STORAGE, + Binder.getCallingPid(), Binder.getCallingUid(), + "need WRITE_EXTERNAL_STORAGE permission to use DESTINATION_FILE_URI"); + checkFileUriDestination(values); + } filteredValues.put(Downloads.Impl.COLUMN_DESTINATION, dest); } Integer vis = values.getAsInteger(Downloads.Impl.COLUMN_VISIBILITY); @@ -313,16 +385,19 @@ public final class DownloadProvider extends ContentProvider { } copyInteger(Downloads.Impl.COLUMN_CONTROL, values, filteredValues); filteredValues.put(Downloads.Impl.COLUMN_STATUS, Downloads.Impl.STATUS_PENDING); - filteredValues.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, System.currentTimeMillis()); + filteredValues.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, + mSystemFacade.currentTimeMillis()); + String pckg = values.getAsString(Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE); String clazz = values.getAsString(Downloads.Impl.COLUMN_NOTIFICATION_CLASS); - if (pckg != null && clazz != null) { + if (pckg != null && (clazz != null || isPublicApi)) { int uid = Binder.getCallingUid(); try { - if (uid == 0 || - getContext().getPackageManager().getApplicationInfo(pckg, 0).uid == uid) { + if (uid == 0 || mSystemFacade.userOwnsPackage(uid, pckg)) { filteredValues.put(Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE, pckg); - filteredValues.put(Downloads.Impl.COLUMN_NOTIFICATION_CLASS, clazz); + if (clazz != null) { + filteredValues.put(Downloads.Impl.COLUMN_NOTIFICATION_CLASS, clazz); + } } } catch (PackageManager.NameNotFoundException ex) { /* ignored for now */ @@ -340,8 +415,14 @@ public final class DownloadProvider extends ContentProvider { if (Binder.getCallingUid() == 0) { copyInteger(Constants.UID, values, filteredValues); } - copyString(Downloads.Impl.COLUMN_TITLE, values, filteredValues); - copyString(Downloads.Impl.COLUMN_DESCRIPTION, values, filteredValues); + copyStringWithDefault(Downloads.Impl.COLUMN_TITLE, values, filteredValues, ""); + copyStringWithDefault(Downloads.Impl.COLUMN_DESCRIPTION, values, filteredValues, ""); + filteredValues.put(Downloads.Impl.COLUMN_TOTAL_BYTES, -1); + + if (isPublicApi) { + copyInteger(Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES, values, filteredValues); + copyBoolean(Downloads.Impl.COLUMN_ALLOW_ROAMING, values, filteredValues); + } if (Constants.LOGVV) { Log.v(Constants.TAG, "initiating download with UID " @@ -356,6 +437,7 @@ public final class DownloadProvider extends ContentProvider { context.startService(new Intent(context, DownloadService.class)); long rowID = db.insert(DB_TABLE, null, filteredValues); + insertRequestHeaders(db, rowID, values); Uri ret = null; @@ -373,6 +455,112 @@ public final class DownloadProvider extends ContentProvider { } /** + * Check that the file URI provided for DESTINATION_FILE_URI is valid. + */ + private void checkFileUriDestination(ContentValues values) { + String fileUri = values.getAsString(Downloads.Impl.COLUMN_FILE_NAME_HINT); + if (fileUri == null) { + throw new IllegalArgumentException( + "DESTINATION_FILE_URI must include a file URI under COLUMN_FILE_NAME_HINT"); + } + Uri uri = Uri.parse(fileUri); + if (!uri.getScheme().equals("file")) { + throw new IllegalArgumentException("Not a file URI: " + uri); + } + File path = new File(uri.getSchemeSpecificPart()); + String externalPath = Environment.getExternalStorageDirectory().getAbsolutePath(); + if (!path.getPath().startsWith(externalPath)) { + throw new SecurityException("Destination must be on external storage: " + uri); + } + } + + /** + * Apps with the ACCESS_DOWNLOAD_MANAGER permission can access this provider freely, subject to + * constraints in the rest of the code. Apps without that may still access this provider through + * the public API, but additional restrictions are imposed. We check those restrictions here. + * + * @param values ContentValues provided to insert() + * @throws SecurityException if the caller has insufficient permissions + */ + private void checkInsertPermissions(ContentValues values) { + if (getContext().checkCallingOrSelfPermission(Downloads.Impl.PERMISSION_ACCESS) + == PackageManager.PERMISSION_GRANTED) { + return; + } + + getContext().enforceCallingOrSelfPermission(android.Manifest.permission.INTERNET, + "INTERNET permission is required to use the download manager"); + + // ensure the request fits within the bounds of a public API request + // first copy so we can remove values + values = new ContentValues(values); + + // check columns whose values are restricted + enforceAllowedValues(values, Downloads.Impl.COLUMN_IS_PUBLIC_API, Boolean.TRUE); + enforceAllowedValues(values, Downloads.Impl.COLUMN_DESTINATION, + Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE, + Downloads.Impl.DESTINATION_FILE_URI); + + if (getContext().checkCallingOrSelfPermission(Downloads.Impl.PERMISSION_NO_NOTIFICATION) + == PackageManager.PERMISSION_GRANTED) { + enforceAllowedValues(values, Downloads.Impl.COLUMN_VISIBILITY, + Downloads.Impl.VISIBILITY_HIDDEN, Downloads.Impl.VISIBILITY_VISIBLE); + } else { + enforceAllowedValues(values, Downloads.Impl.COLUMN_VISIBILITY, + Downloads.Impl.VISIBILITY_VISIBLE); + } + + // remove the rest of the columns that are allowed (with any value) + values.remove(Downloads.Impl.COLUMN_URI); + values.remove(Downloads.Impl.COLUMN_TITLE); + values.remove(Downloads.Impl.COLUMN_DESCRIPTION); + values.remove(Downloads.Impl.COLUMN_MIME_TYPE); + values.remove(Downloads.Impl.COLUMN_FILE_NAME_HINT); // checked later in insert() + values.remove(Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE); // checked later in insert() + values.remove(Downloads.Impl.COLUMN_ALLOWED_NETWORK_TYPES); + values.remove(Downloads.Impl.COLUMN_ALLOW_ROAMING); + Iterator<Map.Entry<String, Object>> iterator = values.valueSet().iterator(); + while (iterator.hasNext()) { + String key = iterator.next().getKey(); + if (key.startsWith(Downloads.Impl.RequestHeaders.INSERT_KEY_PREFIX)) { + iterator.remove(); + } + } + + // any extra columns are extraneous and disallowed + if (values.size() > 0) { + StringBuilder error = new StringBuilder("Invalid columns in request: "); + boolean first = true; + for (Map.Entry<String, Object> entry : values.valueSet()) { + if (!first) { + error.append(", "); + } + error.append(entry.getKey()); + } + throw new SecurityException(error.toString()); + } + } + + /** + * Remove column from values, and throw a SecurityException if the value isn't within the + * specified allowedValues. + */ + private void enforceAllowedValues(ContentValues values, String column, + Object... allowedValues) { + Object value = values.get(column); + values.remove(column); + for (Object allowedValue : allowedValues) { + if (value == null && allowedValue == null) { + return; + } + if (value != null && value.equals(allowedValue)) { + return; + } + } + throw new SecurityException("Invalid value for " + column + ": " + value); + } + + /** * Starts a database query */ @Override @@ -396,10 +584,16 @@ public final class DownloadProvider extends ContentProvider { case DOWNLOADS_ID: { qb.setTables(DB_TABLE); qb.appendWhere(Downloads.Impl._ID + "="); - qb.appendWhere(uri.getPathSegments().get(1)); + qb.appendWhere(getDownloadIdFromUri(uri)); emptyWhere = false; break; } + case REQUEST_HEADERS_URI: + if (projection != null || selection != null || sort != null) { + throw new UnsupportedOperationException("Request header queries do not support " + + "projections, selections or sorting"); + } + return queryRequestHeaders(db, uri); default: { if (Constants.LOGV) { Log.v(Constants.TAG, "querying unknown URI: " + uri); @@ -408,45 +602,22 @@ public final class DownloadProvider extends ContentProvider { } } - int callingUid = Binder.getCallingUid(); - if (Binder.getCallingPid() != Process.myPid() && - callingUid != mSystemUid && - callingUid != mDefContainerUid && - Process.supportsProcesses()) { - boolean canSeeAllExternal; + if (shouldRestrictVisibility()) { if (projection == null) { projection = sAppReadableColumnsArray; - // sAppReadableColumnsArray includes _DATA, which is not allowed - // to be seen except by the initiating application - canSeeAllExternal = false; } else { - canSeeAllExternal = getContext().checkCallingPermission( - Downloads.Impl.PERMISSION_SEE_ALL_EXTERNAL) - == PackageManager.PERMISSION_GRANTED; for (int i = 0; i < projection.length; ++i) { if (!sAppReadableColumnsSet.contains(projection[i])) { throw new IllegalArgumentException( "column " + projection[i] + " is not allowed in queries"); } - canSeeAllExternal = canSeeAllExternal - && !projection[i].equals(Downloads.Impl._DATA); } } if (!emptyWhere) { qb.appendWhere(" AND "); emptyWhere = false; } - String validUid = "( " + Constants.UID + "=" - + Binder.getCallingUid() + " OR " - + Downloads.Impl.COLUMN_OTHER_UID + "=" - + Binder.getCallingUid() + " )"; - if (canSeeAllExternal) { - qb.appendWhere("( " + validUid + " OR " - + Downloads.Impl.DESTINATION_EXTERNAL + " = " - + Downloads.Impl.COLUMN_DESTINATION + " )"); - } else { - qb.appendWhere(validUid); - } + qb.appendWhere(getRestrictedUidClause()); } if (Constants.LOGVV) { @@ -513,6 +684,80 @@ public final class DownloadProvider extends ContentProvider { return ret; } + private String getDownloadIdFromUri(final Uri uri) { + return uri.getPathSegments().get(1); + } + + /** + * Insert request headers for a download into the DB. + */ + private void insertRequestHeaders(SQLiteDatabase db, long downloadId, ContentValues values) { + ContentValues rowValues = new ContentValues(); + rowValues.put(Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID, downloadId); + for (Map.Entry<String, Object> entry : values.valueSet()) { + String key = entry.getKey(); + if (key.startsWith(Downloads.Impl.RequestHeaders.INSERT_KEY_PREFIX)) { + String headerLine = entry.getValue().toString(); + if (!headerLine.contains(":")) { + throw new IllegalArgumentException("Invalid HTTP header line: " + headerLine); + } + String[] parts = headerLine.split(":", 2); + rowValues.put(Downloads.Impl.RequestHeaders.COLUMN_HEADER, parts[0].trim()); + rowValues.put(Downloads.Impl.RequestHeaders.COLUMN_VALUE, parts[1].trim()); + db.insert(Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE, null, rowValues); + } + } + } + + /** + * Handle a query for the custom request headers registered for a download. + */ + private Cursor queryRequestHeaders(SQLiteDatabase db, Uri uri) { + String where = Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID + "=" + + getDownloadIdFromUri(uri); + String[] projection = new String[] {Downloads.Impl.RequestHeaders.COLUMN_HEADER, + Downloads.Impl.RequestHeaders.COLUMN_VALUE}; + Cursor cursor = db.query(Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE, projection, where, + null, null, null, null); + return new ReadOnlyCursorWrapper(cursor); + } + + /** + * Delete request headers for downloads matching the given query. + */ + private void deleteRequestHeaders(SQLiteDatabase db, String where, String[] whereArgs) { + String[] projection = new String[] {Downloads.Impl._ID}; + Cursor cursor = db.query(DB_TABLE, projection, where, whereArgs, null, null, null, null); + try { + for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { + long id = cursor.getLong(0); + String idWhere = Downloads.Impl.RequestHeaders.COLUMN_DOWNLOAD_ID + "=" + id; + db.delete(Downloads.Impl.RequestHeaders.HEADERS_DB_TABLE, idWhere, null); + } + } finally { + cursor.close(); + } + } + + /** + * @return true if we should restrict this caller to viewing only its own downloads + */ + private boolean shouldRestrictVisibility() { + int callingUid = Binder.getCallingUid(); + return Binder.getCallingPid() != Process.myPid() && + callingUid != mSystemUid && + callingUid != mDefContainerUid && + Process.supportsProcesses(); + } + + /** + * @return a SQL WHERE clause to restrict the query to downloads accessible to the caller's UID + */ + private String getRestrictedUidClause() { + return "( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.Impl.COLUMN_OTHER_UID + "=" + Binder.getCallingUid() + " )"; + } + /** * Updates a row in the database */ @@ -558,30 +803,9 @@ public final class DownloadProvider extends ContentProvider { switch (match) { case DOWNLOADS: case DOWNLOADS_ID: { - String myWhere; - if (where != null) { - if (match == DOWNLOADS) { - myWhere = "( " + where + " )"; - } else { - myWhere = "( " + where + " ) AND "; - } - } else { - myWhere = ""; - } - if (match == DOWNLOADS_ID) { - String segment = uri.getPathSegments().get(1); - rowId = Long.parseLong(segment); - myWhere += " ( " + Downloads.Impl._ID + " = " + rowId + " ) "; - } - int callingUid = Binder.getCallingUid(); - if (Binder.getCallingPid() != Process.myPid() && - callingUid != mSystemUid && - callingUid != mDefContainerUid) { - myWhere += " AND ( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " - + Downloads.Impl.COLUMN_OTHER_UID + "=" + Binder.getCallingUid() + " )"; - } + String fullWhere = getWhereClause(uri, where); if (filteredValues.size() > 0) { - count = db.update(DB_TABLE, filteredValues, myWhere, whereArgs); + count = db.update(DB_TABLE, filteredValues, fullWhere, whereArgs); } else { count = 0; } @@ -602,6 +826,22 @@ public final class DownloadProvider extends ContentProvider { return count; } + private String getWhereClause(final Uri uri, final String where) { + StringBuilder myWhere = new StringBuilder(); + if (where != null) { + myWhere.append("( " + where + " )"); + } + if (sURIMatcher.match(uri) == DOWNLOADS_ID) { + String segment = getDownloadIdFromUri(uri); + long rowId = Long.parseLong(segment); + appendClause(myWhere, " ( " + Downloads.Impl._ID + " = " + rowId + " ) "); + } + if (shouldRestrictVisibility()) { + appendClause(myWhere, getRestrictedUidClause()); + } + return myWhere.toString(); + } + /** * Deletes a row in the database */ @@ -617,30 +857,9 @@ public final class DownloadProvider extends ContentProvider { switch (match) { case DOWNLOADS: case DOWNLOADS_ID: { - String myWhere; - if (where != null) { - if (match == DOWNLOADS) { - myWhere = "( " + where + " )"; - } else { - myWhere = "( " + where + " ) AND "; - } - } else { - myWhere = ""; - } - if (match == DOWNLOADS_ID) { - String segment = uri.getPathSegments().get(1); - long rowId = Long.parseLong(segment); - myWhere += " ( " + Downloads.Impl._ID + " = " + rowId + " ) "; - } - int callingUid = Binder.getCallingUid(); - if (Binder.getCallingPid() != Process.myPid() && - callingUid != mSystemUid && - callingUid != mDefContainerUid) { - myWhere += " AND ( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " - + Downloads.Impl.COLUMN_OTHER_UID + "=" - + Binder.getCallingUid() + " )"; - } - count = db.delete(DB_TABLE, myWhere, whereArgs); + String fullWhere = getWhereClause(uri, where); + deleteRequestHeaders(db, fullWhere, whereArgs); + count = db.delete(DB_TABLE, fullWhere, whereArgs); break; } default: { @@ -654,6 +873,13 @@ public final class DownloadProvider extends ContentProvider { return count; } + private void appendClause(StringBuilder whereClause, String newClause) { + if (whereClause.length() != 0) { + whereClause.append(" AND "); + } + whereClause.append(newClause); + } + /** * Remotely opens a file */ @@ -733,7 +959,7 @@ public final class DownloadProvider extends ContentProvider { throw new FileNotFoundException("couldn't open file"); } else { ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, System.currentTimeMillis()); + values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, mSystemFacade.currentTimeMillis()); update(uri, values, null, null); } return ret; @@ -760,6 +986,14 @@ public final class DownloadProvider extends ContentProvider { } } + private static final void copyStringWithDefault(String key, ContentValues from, + ContentValues to, String defaultValue) { + copyString(key, from, to); + if (!to.containsKey(key)) { + to.put(key, defaultValue); + } + } + private class ReadOnlyCursorWrapper extends CursorWrapper implements CrossProcessCursor { public ReadOnlyCursorWrapper(Cursor cursor) { super(cursor); diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java index e8f10e7d..852c3712 100644 --- a/src/com/android/providers/downloads/DownloadReceiver.java +++ b/src/com/android/providers/downloads/DownloadReceiver.java @@ -16,7 +16,6 @@ package com.android.providers.downloads; -import android.app.NotificationManager; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.ContentUris; @@ -25,20 +24,29 @@ import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.ConnectivityManager; +import android.net.DownloadManager; import android.net.NetworkInfo; import android.net.Uri; import android.provider.Downloads; import android.util.Config; import android.util.Log; +import com.google.common.annotations.VisibleForTesting; + import java.io.File; /** * Receives system broadcasts (boot, network connectivity) */ public class DownloadReceiver extends BroadcastReceiver { + @VisibleForTesting + SystemFacade mSystemFacade = null; public void onReceive(Context context, Intent intent) { + if (mSystemFacade == null) { + mSystemFacade = new RealSystemFacade(context); + } + if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { if (Constants.LOGVV) { Log.v(Constants.TAG, "Receiver onBoot"); @@ -52,7 +60,7 @@ public class DownloadReceiver extends BroadcastReceiver { intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO); if (info != null && info.isConnected()) { if (Constants.LOGX) { - if (Helpers.isNetworkAvailable(context)) { + if (Helpers.isNetworkAvailable(mSystemFacade)) { Log.i(Constants.TAG, "Broadcast: Network Up"); } else { Log.i(Constants.TAG, "Broadcast: Network Up, Actually Down"); @@ -61,7 +69,7 @@ public class DownloadReceiver extends BroadcastReceiver { context.startService(new Intent(context, DownloadService.class)); } else { if (Constants.LOGX) { - if (Helpers.isNetworkAvailable(context)) { + if (Helpers.isNetworkAvailable(mSystemFacade)) { Log.i(Constants.TAG, "Broadcast: Network Down, Actually Up"); } else { Log.i(Constants.TAG, "Broadcast: Network Down"); @@ -127,28 +135,35 @@ public class DownloadReceiver extends BroadcastReceiver { Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE); int classColumn = cursor.getColumnIndexOrThrow( Downloads.Impl.COLUMN_NOTIFICATION_CLASS); + int isPublicApiColumn = cursor.getColumnIndex( + Downloads.Impl.COLUMN_IS_PUBLIC_API); String pckg = cursor.getString(packageColumn); String clazz = cursor.getString(classColumn); - if (pckg != null && clazz != null) { - Intent appIntent = - new Intent(Downloads.Impl.ACTION_NOTIFICATION_CLICKED); - appIntent.setClassName(pckg, clazz); - if (intent.getBooleanExtra("multiple", true)) { - appIntent.setData(Downloads.Impl.CONTENT_URI); - } else { - appIntent.setData(intent.getData()); + boolean isPublicApi = cursor.getInt(isPublicApiColumn) != 0; + + if (pckg != null) { + Intent appIntent = null; + if (isPublicApi) { + appIntent = new Intent(DownloadManager.ACTION_NOTIFICATION_CLICKED); + appIntent.setPackage(pckg); + } else if (clazz != null) { // legacy behavior + appIntent = new Intent(Downloads.Impl.ACTION_NOTIFICATION_CLICKED); + appIntent.setClassName(pckg, clazz); + if (intent.getBooleanExtra("multiple", true)) { + appIntent.setData(Downloads.Impl.CONTENT_URI); + } else { + appIntent.setData(intent.getData()); + } + } + if (appIntent != null) { + mSystemFacade.sendBroadcast(appIntent); } - context.sendBroadcast(appIntent); } } } cursor.close(); } - NotificationManager notMgr = (NotificationManager) context - .getSystemService(Context.NOTIFICATION_SERVICE); - if (notMgr != null) { - notMgr.cancel((int) ContentUris.parseId(intent.getData())); - } + mSystemFacade.cancelNotification((int) ContentUris.parseId(intent.getData())); } else if (intent.getAction().equals(Constants.ACTION_HIDE)) { if (Constants.LOGVV) { Log.v(Constants.TAG, "Receiver hide for " + intent.getData()); diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java index 9e890ea0..6d9ee220 100644 --- a/src/com/android/providers/downloads/DownloadService.java +++ b/src/com/android/providers/downloads/DownloadService.java @@ -16,8 +16,6 @@ package com.android.providers.downloads; -import com.google.android.collect.Lists; - import android.app.AlarmManager; import android.app.PendingIntent; import android.app.Service; @@ -44,11 +42,13 @@ import android.provider.Downloads; import android.util.Config; import android.util.Log; +import com.google.android.collect.Lists; +import com.google.common.annotations.VisibleForTesting; + import java.io.File; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; -import java.util.List; /** @@ -62,7 +62,7 @@ public class DownloadService extends Service { /** Observer to get notified when the content observer's data changes */ private DownloadManagerContentObserver mObserver; - + /** Class to handle Notification Manager updates */ private DownloadNotification mNotifier; @@ -78,7 +78,8 @@ public class DownloadService extends Service { * The thread that updates the internal download list from the content * provider. */ - private UpdateThread mUpdateThread; + @VisibleForTesting + UpdateThread mUpdateThread; /** * Whether the internal download list should be updated from the content @@ -109,6 +110,9 @@ public class DownloadService extends Service { */ private CharArrayBuffer mNewChars; + @VisibleForTesting + SystemFacade mSystemFacade; + /* ------------ Inner Classes ------------ */ /** @@ -200,6 +204,10 @@ public class DownloadService extends Service { Log.v(Constants.TAG, "Service onCreate"); } + if (mSystemFacade == null) { + mSystemFacade = new RealSystemFacade(this); + } + mDownloads = Lists.newArrayList(); mObserver = new DownloadManagerContentObserver(); @@ -209,10 +217,10 @@ public class DownloadService extends Service { mMediaScannerService = null; mMediaScannerConnecting = false; mMediaScannerConnection = new MediaScannerConnection(); - - mNotifier = new DownloadNotification(this); - mNotifier.mNotificationMgr.cancelAll(); - mNotifier.updateNotification(); + + mNotifier = new DownloadNotification(this, mSystemFacade); + mSystemFacade.cancelAllNotifications(); + mNotifier.updateNotification(mDownloads); trimDatabase(); removeSpuriousFiles(); @@ -250,7 +258,7 @@ public class DownloadService extends Service { mPendingUpdate = true; if (mUpdateThread == null) { mUpdateThread = new UpdateThread(); - mUpdateThread.start(); + mSystemFacade.startThread(mUpdateThread); } } } @@ -259,10 +267,10 @@ public class DownloadService extends Service { public UpdateThread() { super("Download Service"); } - + public void run() { 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 @@ -292,7 +300,7 @@ public class DownloadService extends Service { DownloadReceiver.class.getName()); alarms.set( AlarmManager.RTC_WAKEUP, - System.currentTimeMillis() + wakeUp, + mSystemFacade.currentTimeMillis() + wakeUp, PendingIntent.getBroadcast(DownloadService.this, 0, intent, PendingIntent.FLAG_ONE_SHOT)); } @@ -303,9 +311,7 @@ public class DownloadService extends Service { } mPendingUpdate = false; } - boolean networkAvailable = Helpers.isNetworkAvailable(DownloadService.this); - boolean networkRoaming = Helpers.isNetworkRoaming(DownloadService.this); - long now = System.currentTimeMillis(); + long now = mSystemFacade.currentTimeMillis(); Cursor cursor = getContentResolver().query(Downloads.Impl.CONTENT_URI, null, null, null, Downloads.Impl._ID); @@ -361,7 +367,7 @@ public class DownloadService extends Service { int id = cursor.getInt(idColumn); if (arrayPos == mDownloads.size()) { - insertDownload(cursor, arrayPos, networkAvailable, networkRoaming, now); + insertDownload(cursor, arrayPos, now); if (Constants.LOGVV) { Log.v(Constants.TAG, "Array update: appending " + id + " @ " + arrayPos); @@ -398,9 +404,7 @@ public class DownloadService extends Service { deleteDownload(arrayPos); // this advances in the array } else if (arrayId == id) { // This cursor row already exists in the stored array - updateDownload( - cursor, arrayPos, - networkAvailable, networkRoaming, now); + updateDownload(cursor, arrayPos, now); if (shouldScanFile(arrayPos) && (!mediaScannerConnected() || !scanFile(cursor, arrayPos))) { @@ -425,9 +429,7 @@ public class DownloadService extends Service { Log.v(Constants.TAG, "Array update: inserting " + id + " @ " + arrayPos); } - insertDownload( - cursor, arrayPos, - networkAvailable, networkRoaming, now); + insertDownload(cursor, arrayPos, now); if (shouldScanFile(arrayPos) && (!mediaScannerConnected() || !scanFile(cursor, arrayPos))) { @@ -451,7 +453,7 @@ public class DownloadService extends Service { } } - mNotifier.updateNotification(); + mNotifier.updateNotification(mDownloads); if (mustScan) { if (!mMediaScannerConnecting) { @@ -544,44 +546,8 @@ public class DownloadService extends Service { * Keeps a local copy of the info about a download, and initiates the * download if appropriate. */ - private void insertDownload( - Cursor cursor, int arrayPos, - boolean networkAvailable, boolean networkRoaming, long now) { - int statusColumn = cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_STATUS); - int failedColumn = cursor.getColumnIndexOrThrow(Constants.FAILED_CONNECTIONS); - int retryRedirect = - cursor.getInt(cursor.getColumnIndexOrThrow(Constants.RETRY_AFTER_X_REDIRECT_COUNT)); - DownloadInfo info = new DownloadInfo( - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl._ID)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_URI)), - cursor.getInt(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_NO_INTEGRITY)) == 1, - cursor.getString(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_FILE_NAME_HINT)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl._DATA)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_MIME_TYPE)), - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_DESTINATION)), - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_VISIBILITY)), - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_CONTROL)), - cursor.getInt(statusColumn), - cursor.getInt(failedColumn), - retryRedirect & 0xfffffff, - retryRedirect >> 28, - cursor.getLong(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_LAST_MODIFICATION)), - cursor.getString(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE)), - cursor.getString(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_NOTIFICATION_CLASS)), - cursor.getString(cursor.getColumnIndexOrThrow( - Downloads.Impl.COLUMN_NOTIFICATION_EXTRAS)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_COOKIE_DATA)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_USER_AGENT)), - cursor.getString(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_REFERER)), - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_TOTAL_BYTES)), - cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_CURRENT_BYTES)), - cursor.getString(cursor.getColumnIndexOrThrow(Constants.ETAG)), - cursor.getInt(cursor.getColumnIndexOrThrow(Constants.MEDIA_SCANNED)) == 1); + private void insertDownload(Cursor cursor, int arrayPos, long now) { + DownloadInfo info = new DownloadInfo(this, mSystemFacade, cursor); if (Constants.LOGVV) { Log.v(Constants.TAG, "Service adding new entry"); @@ -644,51 +610,20 @@ public class DownloadService extends Service { ContentValues values = new ContentValues(); values.put(Downloads.Impl.COLUMN_STATUS, Downloads.Impl.STATUS_NOT_ACCEPTABLE); getContentResolver().update(uri, values, null, null); - info.sendIntentIfRequested(uri, this); + info.sendIntentIfRequested(uri); return; } } - if (info.canUseNetwork(networkAvailable, networkRoaming)) { - if (info.isReadyToStart(now)) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "Service spawning thread to handle new download " + - info.mId); - } - if (info.mHasActiveThread) { - throw new IllegalStateException("Multiple threads on same download on insert"); - } - if (info.mStatus != Downloads.Impl.STATUS_RUNNING) { - info.mStatus = Downloads.Impl.STATUS_RUNNING; - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_STATUS, info.mStatus); - getContentResolver().update( - ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, info.mId), - values, null, null); - } - DownloadThread downloader = new DownloadThread(this, info); - info.mHasActiveThread = true; - downloader.start(); - } - } else { - if (info.mStatus == 0 - || info.mStatus == Downloads.Impl.STATUS_PENDING - || info.mStatus == Downloads.Impl.STATUS_RUNNING) { - info.mStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - Uri uri = ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, info.mId); - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_STATUS, Downloads.Impl.STATUS_RUNNING_PAUSED); - getContentResolver().update(uri, values, null, null); - } + if (info.isReadyToStart(now)) { + info.start(now); } } /** * Updates the local copy of the info about a download. */ - private void updateDownload( - Cursor cursor, int arrayPos, - boolean networkAvailable, boolean networkRoaming, long now) { + private void updateDownload(Cursor cursor, int arrayPos, long now) { DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); int statusColumn = cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_STATUS); int failedColumn = cursor.getColumnIndexOrThrow(Constants.FAILED_CONNECTIONS); @@ -706,7 +641,7 @@ public class DownloadService extends Service { if (info.mVisibility == Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED && newVisibility != Downloads.Impl.VISIBILITY_VISIBLE_NOTIFY_COMPLETED && Downloads.Impl.isStatusCompleted(info.mStatus)) { - mNotifier.mNotificationMgr.cancel(info.mId); + mSystemFacade.cancelNotification(info.mId); } info.mVisibility = newVisibility; synchronized (info) { @@ -716,7 +651,7 @@ public class DownloadService extends Service { int newStatus = cursor.getInt(statusColumn); if (!Downloads.Impl.isStatusCompleted(info.mStatus) && Downloads.Impl.isStatusCompleted(newStatus)) { - mNotifier.mNotificationMgr.cancel(info.mId); + mSystemFacade.cancelNotification(info.mId); } info.mStatus = newStatus; info.mNumFailed = cursor.getInt(failedColumn); @@ -742,25 +677,8 @@ public class DownloadService extends Service { info.mMediaScanned = cursor.getInt(cursor.getColumnIndexOrThrow(Constants.MEDIA_SCANNED)) == 1; - if (info.canUseNetwork(networkAvailable, networkRoaming)) { - if (info.isReadyToRestart(now)) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "Service spawning thread to handle updated download " + - info.mId); - } - if (info.mHasActiveThread) { - throw new IllegalStateException("Multiple threads on same download on update"); - } - info.mStatus = Downloads.Impl.STATUS_RUNNING; - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_STATUS, info.mStatus); - getContentResolver().update( - ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, info.mId), - values, null, null); - DownloadThread downloader = new DownloadThread(this, info); - info.mHasActiveThread = true; - downloader.start(); - } + if (info.isReadyToRestart(now)) { + info.start(now); } } @@ -806,7 +724,7 @@ public class DownloadService extends Service { && info.mFileName != null) { new File(info.mFileName).delete(); } - mNotifier.mNotificationMgr.cancel(info.mId); + mSystemFacade.cancelNotification(info.mId); mDownloads.remove(arrayPos); } diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java index 9e93e6a1..0073c6af 100644 --- a/src/com/android/providers/downloads/DownloadThread.java +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -16,12 +16,7 @@ package com.android.providers.downloads; -import org.apache.http.Header; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.HttpClient; import org.apache.http.conn.params.ConnRouteParams; -import org.apache.http.entity.StringEntity; import android.content.ContentUris; import android.content.ContentValues; @@ -39,9 +34,11 @@ import android.provider.DrmStore; import android.util.Config; import android.util.Log; +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpGet; import java.io.File; -import java.io.FileDescriptor; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; @@ -50,6 +47,7 @@ import java.io.SyncFailedException; import java.net.URI; import java.net.URISyntaxException; import java.util.Locale; +import java.util.Map; /** * Runs an actual download @@ -58,9 +56,11 @@ public class DownloadThread extends Thread { private Context mContext; private DownloadInfo mInfo; + private SystemFacade mSystemFacade; - public DownloadThread(Context context, DownloadInfo info) { + public DownloadThread(Context context, SystemFacade systemFacade, DownloadInfo info) { mContext = context; + mSystemFacade = systemFacade; mInfo = info; } @@ -78,573 +78,121 @@ public class DownloadThread extends Thread { } /** + * State for the entire run() method. + */ + private static class State { + public String mFilename; + public FileOutputStream mStream; + public String mMimeType; + public boolean mCountRetry = false; + public int mRetryAfter = 0; + public int mRedirectCount = 0; + public String mNewUri; + public Uri mContentUri; + public boolean mGotData = false; + public String mRequestUri; + + public State(DownloadInfo info) { + mMimeType = sanitizeMimeType(info.mMimeType); + mRedirectCount = info.mRedirectCount; + mContentUri = Uri.parse(Downloads.Impl.CONTENT_URI + "/" + info.mId); + mRequestUri = info.mUri; + mFilename = info.mFileName; + } + } + + /** + * State within executeDownload() + */ + private static class InnerState { + public int mBytesSoFar = 0; + public String mHeaderETag; + public boolean mContinuingDownload = false; + public String mHeaderContentLength; + public String mHeaderContentDisposition; + public String mHeaderContentLocation; + public int mBytesNotified = 0; + public long mTimeLastNotification = 0; + } + + /** + * Raised from methods called by run() to indicate that the current request should be stopped + * immediately. + */ + private class StopRequest extends Throwable { + public int mFinalStatus; + + public StopRequest(int finalStatus) { + mFinalStatus = finalStatus; + } + + public StopRequest(int finalStatus, Throwable throwable) { + super(throwable); + mFinalStatus = finalStatus; + } + } + + /** + * Raised from methods called by executeDownload() to indicate that the download should be + * retried immediately. + */ + private class RetryDownload extends Throwable {} + + /** * Executes the download in a separate thread */ public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); - int finalStatus = Downloads.Impl.STATUS_UNKNOWN_ERROR; - boolean countRetry = false; - int retryAfter = 0; - int redirectCount = mInfo.mRedirectCount; - String newUri = null; - boolean gotData = false; - String filename = null; - String mimeType = sanitizeMimeType(mInfo.mMimeType); - FileOutputStream stream = null; + State state = new State(mInfo); AndroidHttpClient client = null; PowerManager.WakeLock wakeLock = null; - Uri contentUri = Uri.parse(Downloads.Impl.CONTENT_URI + "/" + mInfo.mId); + int finalStatus = Downloads.Impl.STATUS_UNKNOWN_ERROR; + mInfo.mPausedReason = null; try { - boolean continuingDownload = false; - String headerAcceptRanges = null; - String headerContentDisposition = null; - String headerContentLength = null; - String headerContentLocation = null; - String headerETag = null; - String headerTransferEncoding = null; - - byte data[] = new byte[Constants.BUFFER_SIZE]; - - int bytesSoFar = 0; - PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG); wakeLock.acquire(); - filename = mInfo.mFileName; - if (filename != null) { - if (!Helpers.isFilenameValid(filename)) { - finalStatus = Downloads.Impl.STATUS_FILE_ERROR; - notifyDownloadCompleted( - finalStatus, false, 0, 0, false, filename, null, mInfo.mMimeType); - return; - } - // We're resuming a download that got interrupted - File f = new File(filename); - if (f.exists()) { - long fileLength = f.length(); - if (fileLength == 0) { - // The download hadn't actually started, we can restart from scratch - f.delete(); - filename = null; - } else if (mInfo.mETag == null && !mInfo.mNoIntegrity) { - // Tough luck, that's not a resumable download - if (Config.LOGD) { - Log.d(Constants.TAG, - "can't resume interrupted non-resumable download"); - } - f.delete(); - finalStatus = Downloads.Impl.STATUS_PRECONDITION_FAILED; - notifyDownloadCompleted( - finalStatus, false, 0, 0, false, filename, null, mInfo.mMimeType); - return; - } else { - // All right, we'll be able to resume this download - stream = new FileOutputStream(filename, true); - bytesSoFar = (int) fileLength; - if (mInfo.mTotalBytes != -1) { - headerContentLength = Integer.toString(mInfo.mTotalBytes); - } - headerETag = mInfo.mETag; - continuingDownload = true; - } - } - } - int bytesNotified = bytesSoFar; - // starting with MIN_VALUE means that the first write will commit - // progress to the database - long timeLastNotification = 0; + if (Constants.LOGV) { + Log.v(Constants.TAG, "initiating download for " + mInfo.mUri); + } client = AndroidHttpClient.newInstance(userAgent(), mContext); - if (stream != null && mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL - && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING - .equalsIgnoreCase(mimeType)) { - try { - stream.close(); - stream = null; - } catch (IOException ex) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "exception when closing the file before download : " + - ex); - } - // nothing can really be done if the file can't be closed - } - } - - /* - * This loop is run once for every individual HTTP request that gets sent. - * The very first HTTP request is a "virgin" request, while every subsequent - * request is done with the original ETag and a byte-range. - */ -http_request_loop: - while (true) { + boolean finished = false; + while(!finished) { // Set or unset proxy, which may have changed since last GET request. // setDefaultProxy() supports null as proxy parameter. ConnRouteParams.setDefaultProxy(client.getParams(), - Proxy.getPreferredHttpHost(mContext, mInfo.mUri)); - // Prepares the request and fires it. - HttpGet request = new HttpGet(mInfo.mUri); - - if (Constants.LOGV) { - Log.v(Constants.TAG, "initiating download for " + mInfo.mUri); - } - - if (mInfo.mCookies != null) { - request.addHeader("Cookie", mInfo.mCookies); - } - if (mInfo.mReferer != null) { - request.addHeader("Referer", mInfo.mReferer); - } - if (continuingDownload) { - if (headerETag != null) { - request.addHeader("If-Match", headerETag); - } - request.addHeader("Range", "bytes=" + bytesSoFar + "-"); - } - - HttpResponse response; + Proxy.getPreferredHttpHost(mContext, state.mRequestUri)); + HttpGet request = new HttpGet(state.mRequestUri); try { - response = client.execute(request); - } catch (IllegalArgumentException ex) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "Arg exception trying to execute request for " + - mInfo.mUri + " : " + ex); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "Arg exception trying to execute request for " + - mInfo.mId + " : " + ex); - } - finalStatus = Downloads.Impl.STATUS_BAD_REQUEST; - request.abort(); - break http_request_loop; - } catch (IOException ex) { - if (Constants.LOGX) { - if (Helpers.isNetworkAvailable(mContext)) { - Log.i(Constants.TAG, "Execute Failed " + mInfo.mId + ", Net Up"); - } else { - Log.i(Constants.TAG, "Execute Failed " + mInfo.mId + ", Net Down"); - } - } - if (!Helpers.isNetworkAvailable(mContext)) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - countRetry = true; - } else { - if (Constants.LOGV) { - Log.d(Constants.TAG, "IOException trying to execute request for " + - mInfo.mUri + " : " + ex); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "IOException trying to execute request for " + - mInfo.mId + " : " + ex); - } - finalStatus = Downloads.Impl.STATUS_HTTP_DATA_ERROR; - } - request.abort(); - break http_request_loop; - } - - int statusCode = response.getStatusLine().getStatusCode(); - if (statusCode == 503 && mInfo.mNumFailed < Constants.MAX_RETRIES) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "got HTTP response code 503"); - } - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - countRetry = true; - Header header = response.getFirstHeader("Retry-After"); - if (header != null) { - try { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Retry-After :" + header.getValue()); - } - retryAfter = Integer.parseInt(header.getValue()); - if (retryAfter < 0) { - retryAfter = 0; - } else { - if (retryAfter < Constants.MIN_RETRY_AFTER) { - retryAfter = Constants.MIN_RETRY_AFTER; - } else if (retryAfter > Constants.MAX_RETRY_AFTER) { - retryAfter = Constants.MAX_RETRY_AFTER; - } - retryAfter += Helpers.sRandom.nextInt(Constants.MIN_RETRY_AFTER + 1); - retryAfter *= 1000; - } - } catch (NumberFormatException ex) { - // ignored - retryAfter stays 0 in this case. - } - } + executeDownload(state, client, request); + finished = true; + } catch (RetryDownload exc) { + // fall through + } finally { request.abort(); - break http_request_loop; - } - if (statusCode == 301 || - statusCode == 302 || - statusCode == 303 || - statusCode == 307) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "got HTTP redirect " + statusCode); - } - if (redirectCount >= Constants.MAX_REDIRECTS) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "too many redirects for download " + mInfo.mId + - " at " + mInfo.mUri); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "too many redirects for download " + mInfo.mId); - } - finalStatus = Downloads.Impl.STATUS_TOO_MANY_REDIRECTS; - request.abort(); - break http_request_loop; - } - Header header = response.getFirstHeader("Location"); - if (header != null) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Location :" + header.getValue()); - } - try { - newUri = new URI(mInfo.mUri). - resolve(new URI(header.getValue())). - toString(); - } catch(URISyntaxException ex) { - if (Constants.LOGV) { - Log.d(Constants.TAG, - "Couldn't resolve redirect URI " + - header.getValue() + - " for " + - mInfo.mUri); - } else if (Config.LOGD) { - Log.d(Constants.TAG, - "Couldn't resolve redirect URI for download " + - mInfo.mId); - } - finalStatus = Downloads.Impl.STATUS_BAD_REQUEST; - request.abort(); - break http_request_loop; - } - ++redirectCount; - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - request.abort(); - break http_request_loop; - } + request = null; } - if ((!continuingDownload && statusCode != Downloads.Impl.STATUS_SUCCESS) - || (continuingDownload && statusCode != 206)) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "http error " + statusCode + " for " + mInfo.mUri); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "http error " + statusCode + " for download " + - mInfo.mId); - } - if (Downloads.Impl.isStatusError(statusCode)) { - finalStatus = statusCode; - } else if (statusCode >= 300 && statusCode < 400) { - finalStatus = Downloads.Impl.STATUS_UNHANDLED_REDIRECT; - } else if (continuingDownload && statusCode == Downloads.Impl.STATUS_SUCCESS) { - finalStatus = Downloads.Impl.STATUS_PRECONDITION_FAILED; - } else { - finalStatus = Downloads.Impl.STATUS_UNHANDLED_HTTP_CODE; - } - request.abort(); - break http_request_loop; - } else { - // Handles the response, saves the file - if (Constants.LOGV) { - Log.v(Constants.TAG, "received response for " + mInfo.mUri); - } - - if (!continuingDownload) { - Header header = response.getFirstHeader("Accept-Ranges"); - if (header != null) { - headerAcceptRanges = header.getValue(); - } - header = response.getFirstHeader("Content-Disposition"); - if (header != null) { - headerContentDisposition = header.getValue(); - } - header = response.getFirstHeader("Content-Location"); - if (header != null) { - headerContentLocation = header.getValue(); - } - if (mimeType == null) { - header = response.getFirstHeader("Content-Type"); - if (header != null) { - mimeType = sanitizeMimeType(header.getValue()); - } - } - header = response.getFirstHeader("ETag"); - if (header != null) { - headerETag = header.getValue(); - } - header = response.getFirstHeader("Transfer-Encoding"); - if (header != null) { - headerTransferEncoding = header.getValue(); - } - if (headerTransferEncoding == null) { - header = response.getFirstHeader("Content-Length"); - if (header != null) { - headerContentLength = header.getValue(); - } - } else { - // Ignore content-length with transfer-encoding - 2616 4.4 3 - if (Constants.LOGVV) { - Log.v(Constants.TAG, - "ignoring content-length because of xfer-encoding"); - } - } - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Accept-Ranges: " + headerAcceptRanges); - Log.v(Constants.TAG, "Content-Disposition: " + - headerContentDisposition); - Log.v(Constants.TAG, "Content-Length: " + headerContentLength); - Log.v(Constants.TAG, "Content-Location: " + headerContentLocation); - Log.v(Constants.TAG, "Content-Type: " + mimeType); - Log.v(Constants.TAG, "ETag: " + headerETag); - Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding); - } - - if (!mInfo.mNoIntegrity && headerContentLength == null && - (headerTransferEncoding == null - || !headerTransferEncoding.equalsIgnoreCase("chunked")) - ) { - if (Config.LOGD) { - Log.d(Constants.TAG, "can't know size of download, giving up"); - } - finalStatus = Downloads.Impl.STATUS_LENGTH_REQUIRED; - request.abort(); - break http_request_loop; - } - - DownloadFileInfo fileInfo = Helpers.generateSaveFile( - mContext, - mInfo.mUri, - mInfo.mHint, - headerContentDisposition, - headerContentLocation, - mimeType, - mInfo.mDestination, - (headerContentLength != null) ? - Integer.parseInt(headerContentLength) : 0); - if (fileInfo.mFileName == null) { - finalStatus = fileInfo.mStatus; - request.abort(); - break http_request_loop; - } - filename = fileInfo.mFileName; - stream = fileInfo.mStream; - if (Constants.LOGV) { - Log.v(Constants.TAG, "writing " + mInfo.mUri + " to " + filename); - } - - ContentValues values = new ContentValues(); - values.put(Downloads.Impl._DATA, filename); - if (headerETag != null) { - values.put(Constants.ETAG, headerETag); - } - if (mimeType != null) { - values.put(Downloads.Impl.COLUMN_MIME_TYPE, mimeType); - } - int contentLength = -1; - if (headerContentLength != null) { - contentLength = Integer.parseInt(headerContentLength); - } - values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, contentLength); - mContext.getContentResolver().update(contentUri, values, null, null); - } + } - InputStream entityStream; - try { - entityStream = response.getEntity().getContent(); - } catch (IOException ex) { - if (Constants.LOGX) { - if (Helpers.isNetworkAvailable(mContext)) { - Log.i(Constants.TAG, "Get Failed " + mInfo.mId + ", Net Up"); - } else { - Log.i(Constants.TAG, "Get Failed " + mInfo.mId + ", Net Down"); - } - } - if (!Helpers.isNetworkAvailable(mContext)) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - countRetry = true; - } else { - if (Constants.LOGV) { - Log.d(Constants.TAG, - "IOException getting entity for " + - mInfo.mUri + - " : " + - ex); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "IOException getting entity for download " + - mInfo.mId + " : " + ex); - } - finalStatus = Downloads.Impl.STATUS_HTTP_DATA_ERROR; - } - request.abort(); - break http_request_loop; - } - for (;;) { - int bytesRead; - try { - bytesRead = entityStream.read(data); - } catch (IOException ex) { - if (Constants.LOGX) { - if (Helpers.isNetworkAvailable(mContext)) { - Log.i(Constants.TAG, "Read Failed " + mInfo.mId + ", Net Up"); - } else { - Log.i(Constants.TAG, "Read Failed " + mInfo.mId + ", Net Down"); - } - } - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, bytesSoFar); - mContext.getContentResolver().update(contentUri, values, null, null); - if (!mInfo.mNoIntegrity && headerETag == null) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "download IOException for " + mInfo.mUri + - " : " + ex); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "download IOException for download " + - mInfo.mId + " : " + ex); - } - if (Config.LOGD) { - Log.d(Constants.TAG, - "can't resume interrupted download with no ETag"); - } - finalStatus = Downloads.Impl.STATUS_PRECONDITION_FAILED; - } else if (!Helpers.isNetworkAvailable(mContext)) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - countRetry = true; - } else { - if (Constants.LOGV) { - Log.v(Constants.TAG, "download IOException for " + mInfo.mUri + - " : " + ex); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "download IOException for download " + - mInfo.mId + " : " + ex); - } - finalStatus = Downloads.Impl.STATUS_HTTP_DATA_ERROR; - } - request.abort(); - break http_request_loop; - } - if (bytesRead == -1) { // success - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, bytesSoFar); - if (headerContentLength == null) { - values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, bytesSoFar); - } - mContext.getContentResolver().update(contentUri, values, null, null); - if ((headerContentLength != null) - && (bytesSoFar - != Integer.parseInt(headerContentLength))) { - if (!mInfo.mNoIntegrity && headerETag == null) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "mismatched content length " + - mInfo.mUri); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "mismatched content length for " + - mInfo.mId); - } - finalStatus = Downloads.Impl.STATUS_LENGTH_REQUIRED; - } else if (!Helpers.isNetworkAvailable(mContext)) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - countRetry = true; - } else { - if (Constants.LOGV) { - Log.v(Constants.TAG, "closed socket for " + mInfo.mUri); - } else if (Config.LOGD) { - Log.d(Constants.TAG, "closed socket for download " + - mInfo.mId); - } - finalStatus = Downloads.Impl.STATUS_HTTP_DATA_ERROR; - } - break http_request_loop; - } - break; - } - gotData = true; - for (;;) { - try { - if (stream == null) { - stream = new FileOutputStream(filename, true); - } - stream.write(data, 0, bytesRead); - if (mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL - && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING - .equalsIgnoreCase(mimeType)) { - try { - stream.close(); - stream = null; - } catch (IOException ex) { - if (Constants.LOGV) { - Log.v(Constants.TAG, - "exception when closing the file " + - "during download : " + ex); - } - // nothing can really be done if the file can't be closed - } - } - break; - } catch (IOException ex) { - if (!Helpers.discardPurgeableFiles( - mContext, Constants.BUFFER_SIZE)) { - finalStatus = Downloads.Impl.STATUS_FILE_ERROR; - break http_request_loop; - } - } - } - bytesSoFar += bytesRead; - long now = System.currentTimeMillis(); - if (bytesSoFar - bytesNotified > Constants.MIN_PROGRESS_STEP - && now - timeLastNotification - > Constants.MIN_PROGRESS_TIME) { - ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, bytesSoFar); - mContext.getContentResolver().update( - contentUri, values, null, null); - bytesNotified = bytesSoFar; - timeLastNotification = now; - } - - if (Constants.LOGVV) { - Log.v(Constants.TAG, "downloaded " + bytesSoFar + " for " + mInfo.mUri); - } - synchronized (mInfo) { - if (mInfo.mControl == Downloads.Impl.CONTROL_PAUSED) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "paused " + mInfo.mUri); - } - finalStatus = Downloads.Impl.STATUS_RUNNING_PAUSED; - request.abort(); - break http_request_loop; - } - } - if (mInfo.mStatus == Downloads.Impl.STATUS_CANCELED) { - if (Constants.LOGV) { - Log.d(Constants.TAG, "canceled " + mInfo.mUri); - } else if (Config.LOGD) { - // Log.d(Constants.TAG, "canceled id " + mInfo.mId); - } - finalStatus = Downloads.Impl.STATUS_CANCELED; - break http_request_loop; - } - } - if (Constants.LOGV) { - Log.v(Constants.TAG, "download completed for " + mInfo.mUri); - } - finalStatus = Downloads.Impl.STATUS_SUCCESS; - } - break; + if (Constants.LOGV) { + Log.v(Constants.TAG, "download completed for " + mInfo.mUri); } - } catch (FileNotFoundException ex) { - if (Config.LOGD) { - Log.d(Constants.TAG, "FileNotFoundException for " + filename + " : " + ex); + finalizeDestinationFile(state); + finalStatus = Downloads.Impl.STATUS_SUCCESS; + } catch (StopRequest error) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "Aborting request for " + mInfo.mUri, error); } + finalStatus = error.mFinalStatus; + // fall through to finally block + } catch (FileNotFoundException ex) { + Log.d(Constants.TAG, "FileNotFoundException for " + state.mFilename + " : " + ex); finalStatus = Downloads.Impl.STATUS_FILE_ERROR; // falls through to the code that reports an error } catch (RuntimeException ex) { //sometimes the socket code throws unchecked exceptions @@ -665,69 +213,656 @@ http_request_loop: client.close(); client = null; } + cleanupDestination(state, finalStatus); + notifyDownloadCompleted(finalStatus, state.mCountRetry, state.mRetryAfter, + state.mRedirectCount, state.mGotData, state.mFilename, + state.mNewUri, state.mMimeType); + } + } + + /** + * Fully execute a single download request - setup and send the request, handle the response, + * and transfer the data to the destination file. + */ + private void executeDownload(State state, AndroidHttpClient client, HttpGet request) + throws StopRequest, RetryDownload, FileNotFoundException { + InnerState innerState = new InnerState(); + byte data[] = new byte[Constants.BUFFER_SIZE]; + + setupDestinationFile(state, innerState); + addRequestHeaders(innerState, request); + + // check just before sending the request to avoid using an invalid connection at all + checkConnectivity(state); + + HttpResponse response = sendRequest(state, client, request); + handleExceptionalStatus(state, innerState, response); + + if (Constants.LOGV) { + Log.v(Constants.TAG, "received response for " + mInfo.mUri); + } + + processResponseHeaders(state, innerState, response); + InputStream entityStream = openResponseEntity(state, response); + transferData(state, innerState, data, entityStream); + } + + /** + * Check if current connectivity is valid for this request. + */ + private void checkConnectivity(State state) throws StopRequest { + if (!mInfo.canUseNetwork()) { + throw new StopRequest(Downloads.Impl.STATUS_RUNNING_PAUSED); + } + } + + /** + * Transfer as much data as possible from the HTTP response to the destination file. + * @param data buffer to use to read data + * @param entityStream stream for reading the HTTP response entity + */ + private void transferData(State state, InnerState innerState, byte[] data, + InputStream entityStream) throws StopRequest { + for (;;) { + int bytesRead = readFromResponse(state, innerState, data, entityStream); + if (bytesRead == -1) { // success, end of stream already reached + handleEndOfStream(state, innerState); + return; + } + + state.mGotData = true; + writeDataToDestination(state, data, bytesRead); + innerState.mBytesSoFar += bytesRead; + reportProgress(state, innerState); + + if (Constants.LOGVV) { + Log.v(Constants.TAG, "downloaded " + innerState.mBytesSoFar + " for " + + mInfo.mUri); + } + + checkPausedOrCanceled(state); + } + } + + /** + * Called after a successful completion to take any necessary action on the downloaded file. + */ + private void finalizeDestinationFile(State state) throws StopRequest { + if (isDrmFile(state)) { + transferToDrm(state); + } else { + // make sure the file is readable + FileUtils.setPermissions(state.mFilename, 0644, -1, -1); + syncDestination(state); + } + } + + /** + * Called just before the thread finishes, regardless of status, to take any necessary action on + * the downloaded file. + */ + private void cleanupDestination(State state, int finalStatus) { + closeDestination(state); + if (state.mFilename != null && Downloads.Impl.isStatusError(finalStatus)) { + new File(state.mFilename).delete(); + state.mFilename = null; + } + } + + /** + * Sync the destination file to storage. + */ + private void syncDestination(State state) { + FileOutputStream downloadedFileStream = null; + try { + downloadedFileStream = new FileOutputStream(state.mFilename, true); + downloadedFileStream.getFD().sync(); + } catch (FileNotFoundException ex) { + Log.w(Constants.TAG, "file " + state.mFilename + " not found: " + ex); + } catch (SyncFailedException ex) { + Log.w(Constants.TAG, "file " + state.mFilename + " sync failed: " + ex); + } catch (IOException ex) { + Log.w(Constants.TAG, "IOException trying to sync " + state.mFilename + ": " + ex); + } catch (RuntimeException ex) { + Log.w(Constants.TAG, "exception while syncing file: ", ex); + } finally { + if(downloadedFileStream != null) { + try { + downloadedFileStream.close(); + } catch (IOException ex) { + Log.w(Constants.TAG, "IOException while closing synced file: ", ex); + } catch (RuntimeException ex) { + Log.w(Constants.TAG, "exception while closing file: ", ex); + } + } + } + } + + /** + * @return true if the current download is a DRM file + */ + private boolean isDrmFile(State state) { + return DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(state.mMimeType); + } + + /** + * Transfer the downloaded destination file to the DRM store. + */ + private void transferToDrm(State state) throws StopRequest { + File file = new File(state.mFilename); + Intent item = DrmStore.addDrmFile(mContext.getContentResolver(), file, null); + file.delete(); + + if (item == null) { + Log.w(Constants.TAG, "unable to add file " + state.mFilename + " to DrmProvider"); + throw new StopRequest(Downloads.Impl.STATUS_UNKNOWN_ERROR); + } else { + state.mFilename = item.getDataString(); + state.mMimeType = item.getType(); + } + } + + /** + * Close the destination output stream. + */ + private void closeDestination(State state) { + try { + // close the file + if (state.mStream != null) { + state.mStream.close(); + state.mStream = null; + } + } catch (IOException ex) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "exception when closing the file after download : " + ex); + } + // nothing can really be done if the file can't be closed + } + } + + /** + * Check if the download has been paused or canceled, stopping the request appropriately if it + * has been. + */ + private void checkPausedOrCanceled(State state) throws StopRequest { + synchronized (mInfo) { + if (mInfo.mControl == Downloads.Impl.CONTROL_PAUSED) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "paused " + mInfo.mUri); + } + throw new StopRequest(Downloads.Impl.STATUS_RUNNING_PAUSED); + } + } + if (mInfo.mStatus == Downloads.Impl.STATUS_CANCELED) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "canceled " + mInfo.mUri); + } + throw new StopRequest(Downloads.Impl.STATUS_CANCELED); + } + } + + /** + * Report download progress through the database if necessary. + */ + private void reportProgress(State state, InnerState innerState) { + long now = mSystemFacade.currentTimeMillis(); + if (innerState.mBytesSoFar - innerState.mBytesNotified + > Constants.MIN_PROGRESS_STEP + && now - innerState.mTimeLastNotification + > Constants.MIN_PROGRESS_TIME) { + ContentValues values = new ContentValues(); + values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, innerState.mBytesSoFar); + mContext.getContentResolver().update( + state.mContentUri, values, null, null); + innerState.mBytesNotified = innerState.mBytesSoFar; + innerState.mTimeLastNotification = now; + } + } + + /** + * Write a data buffer to the destination file. + * @param data buffer containing the data to write + * @param bytesRead how many bytes to write from the buffer + */ + private void writeDataToDestination(State state, byte[] data, int bytesRead) + throws StopRequest { + for (;;) { try { - // close the file - if (stream != null) { - stream.close(); + if (state.mStream == null) { + state.mStream = new FileOutputStream(state.mFilename, true); + } + state.mStream.write(data, 0, bytesRead); + if (mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL + && !isDrmFile(state)) { + closeDestination(state); } + return; } catch (IOException ex) { + if (mInfo.isOnCache() + && Helpers.discardPurgeableFiles(mContext, Constants.BUFFER_SIZE)) { + continue; + } + throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR, ex); + } + } + } + + /** + * 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 { + ContentValues values = new ContentValues(); + values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, innerState.mBytesSoFar); + if (innerState.mHeaderContentLength == null) { + values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, innerState.mBytesSoFar); + } + mContext.getContentResolver().update(state.mContentUri, values, null, null); + + boolean lengthMismatched = (innerState.mHeaderContentLength != null) + && (innerState.mBytesSoFar != Integer.parseInt(innerState.mHeaderContentLength)); + if (lengthMismatched) { + if (cannotResume(innerState)) { if (Constants.LOGV) { - Log.v(Constants.TAG, "exception when closing the file after download : " + ex); + Log.d(Constants.TAG, "mismatched content length " + + mInfo.mUri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "mismatched content length for " + + mInfo.mId); } - // nothing can really be done if the file can't be closed + throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME); + } else { + throw new StopRequest(handleHttpError(state, "closed socket")); } - if (filename != null) { - // if the download wasn't successful, delete the file - if (Downloads.Impl.isStatusError(finalStatus)) { - new File(filename).delete(); - filename = null; - } else if (Downloads.Impl.isStatusSuccess(finalStatus) && - DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING - .equalsIgnoreCase(mimeType)) { - // transfer the file to the DRM content provider - File file = new File(filename); - Intent item = DrmStore.addDrmFile(mContext.getContentResolver(), file, null); - if (item == null) { - Log.w(Constants.TAG, "unable to add file " + filename + " to DrmProvider"); - finalStatus = Downloads.Impl.STATUS_UNKNOWN_ERROR; - } else { - filename = item.getDataString(); - mimeType = item.getType(); - } - - file.delete(); - } else if (Downloads.Impl.isStatusSuccess(finalStatus)) { - // make sure the file is readable - FileUtils.setPermissions(filename, 0644, -1, -1); - - // Sync to storage after completion - FileOutputStream downloadedFileStream = null; - try { - downloadedFileStream = new FileOutputStream(filename, true); - downloadedFileStream.getFD().sync(); - } catch (FileNotFoundException ex) { - Log.w(Constants.TAG, "file " + filename + " not found: " + ex); - } catch (SyncFailedException ex) { - Log.w(Constants.TAG, "file " + filename + " sync failed: " + ex); - } catch (IOException ex) { - Log.w(Constants.TAG, "IOException trying to sync " + filename + ": " + ex); - } catch (RuntimeException ex) { - Log.w(Constants.TAG, "exception while syncing file: ", ex); - } finally { - if(downloadedFileStream != null) { - try { - downloadedFileStream.close(); - } catch (IOException ex) { - Log.w(Constants.TAG, "IOException while closing synced file: ", ex); - } catch (RuntimeException ex) { - Log.w(Constants.TAG, "exception while closing file: ", ex); - } - } + } + } + + private boolean cannotResume(InnerState innerState) { + return innerState.mBytesSoFar > 0 && !mInfo.mNoIntegrity && innerState.mHeaderETag == null; + } + + /** + * Read some data from the HTTP response stream, handling I/O errors. + * @param data buffer to use to read data + * @param entityStream stream for reading the HTTP response entity + * @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 { + try { + return entityStream.read(data); + } catch (IOException ex) { + logNetworkState(); + ContentValues values = new ContentValues(); + values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, innerState.mBytesSoFar); + mContext.getContentResolver().update(state.mContentUri, values, null, null); + if (cannotResume(innerState)) { + Log.d(Constants.TAG, "download IOException for download " + mInfo.mId, ex); + Log.d(Constants.TAG, "can't resume interrupted download with no ETag"); + throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME, ex); + } else { + throw new StopRequest(handleHttpError(state, "download IOException"), ex); + } + } + } + + /** + * Open a stream for the HTTP response entity, handling I/O errors. + * @return an InputStream to read the response entity + */ + private InputStream openResponseEntity(State state, HttpResponse response) + throws StopRequest { + try { + return response.getEntity().getContent(); + } catch (IOException ex) { + logNetworkState(); + throw new StopRequest(handleHttpError(state, "IOException getting entity"), ex); + } + } + + private void logNetworkState() { + if (Constants.LOGX) { + Log.i(Constants.TAG, + "Net " + (Helpers.isNetworkAvailable(mSystemFacade) ? "Up" : "Down")); + } + } + + /** + * Read HTTP response headers and take appropriate action, including setting up the destination + * file and updating the database. + */ + private void processResponseHeaders(State state, InnerState innerState, HttpResponse response) + throws StopRequest, FileNotFoundException { + if (innerState.mContinuingDownload) { + // ignore response headers on resume requests + return; + } + + readResponseHeaders(state, innerState, response); + + DownloadFileInfo fileInfo = 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); + if (fileInfo.mFileName == null) { + throw new StopRequest(fileInfo.mStatus); + } + state.mFilename = fileInfo.mFileName; + state.mStream = fileInfo.mStream; + if (Constants.LOGV) { + Log.v(Constants.TAG, "writing " + mInfo.mUri + " to " + state.mFilename); + } + + updateDatabaseFromHeaders(state, innerState); + // check connectivity again now that we know the total size + checkConnectivity(state); + } + + /** + * Update necessary database fields based on values of HTTP response headers that have been + * read. + */ + private void updateDatabaseFromHeaders(State state, InnerState innerState) { + ContentValues values = new ContentValues(); + values.put(Downloads.Impl._DATA, state.mFilename); + if (innerState.mHeaderETag != null) { + values.put(Constants.ETAG, innerState.mHeaderETag); + } + if (state.mMimeType != null) { + values.put(Downloads.Impl.COLUMN_MIME_TYPE, state.mMimeType); + } + values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, mInfo.mTotalBytes); + mContext.getContentResolver().update(state.mContentUri, values, null, null); + } + + /** + * Read headers from the HTTP response and store them into local state. + */ + private void readResponseHeaders(State state, InnerState innerState, HttpResponse response) + throws StopRequest { + Header header = response.getFirstHeader("Content-Disposition"); + if (header != null) { + innerState.mHeaderContentDisposition = header.getValue(); + } + header = response.getFirstHeader("Content-Location"); + if (header != null) { + innerState.mHeaderContentLocation = header.getValue(); + } + if (state.mMimeType == null) { + header = response.getFirstHeader("Content-Type"); + if (header != null) { + state.mMimeType = sanitizeMimeType(header.getValue()); + } + } + header = response.getFirstHeader("ETag"); + if (header != null) { + innerState.mHeaderETag = header.getValue(); + } + String headerTransferEncoding = null; + header = response.getFirstHeader("Transfer-Encoding"); + if (header != null) { + headerTransferEncoding = header.getValue(); + } + if (headerTransferEncoding == null) { + header = response.getFirstHeader("Content-Length"); + if (header != null) { + innerState.mHeaderContentLength = header.getValue(); + mInfo.mTotalBytes = Long.parseLong(innerState.mHeaderContentLength); + } + } else { + // Ignore content-length with transfer-encoding - 2616 4.4 3 + if (Constants.LOGVV) { + Log.v(Constants.TAG, + "ignoring content-length because of xfer-encoding"); + } + } + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Content-Disposition: " + + innerState.mHeaderContentDisposition); + Log.v(Constants.TAG, "Content-Length: " + innerState.mHeaderContentLength); + Log.v(Constants.TAG, "Content-Location: " + innerState.mHeaderContentLocation); + Log.v(Constants.TAG, "Content-Type: " + state.mMimeType); + Log.v(Constants.TAG, "ETag: " + innerState.mHeaderETag); + Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding); + } + + boolean noSizeInfo = innerState.mHeaderContentLength == null + && (headerTransferEncoding == null + || !headerTransferEncoding.equalsIgnoreCase("chunked")); + if (!mInfo.mNoIntegrity && noSizeInfo) { + Log.d(Constants.TAG, "can't know size of download, giving up"); + throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR); + } + } + + /** + * 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 { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == 503 && mInfo.mNumFailed < Constants.MAX_RETRIES) { + handleServiceUnavailable(state, response); + } + if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307) { + handleRedirect(state, response, statusCode); + } + + int expectedStatus = innerState.mContinuingDownload ? 206 : Downloads.Impl.STATUS_SUCCESS; + if (statusCode != expectedStatus) { + handleOtherStatus(state, innerState, statusCode); + } + } + + /** + * Handle a status that we don't know how to deal with properly. + */ + private void handleOtherStatus(State state, InnerState innerState, int statusCode) + throws StopRequest { + if (Constants.LOGV) { + Log.d(Constants.TAG, "http error " + statusCode + " for " + mInfo.mUri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "http error " + statusCode + " for download " + + mInfo.mId); + } + int finalStatus; + if (Downloads.Impl.isStatusError(statusCode)) { + finalStatus = statusCode; + } else if (statusCode >= 300 && statusCode < 400) { + finalStatus = Downloads.Impl.STATUS_UNHANDLED_REDIRECT; + } else if (innerState.mContinuingDownload && statusCode == Downloads.Impl.STATUS_SUCCESS) { + finalStatus = Downloads.Impl.STATUS_CANNOT_RESUME; + } else { + finalStatus = Downloads.Impl.STATUS_UNHANDLED_HTTP_CODE; + } + throw new StopRequest(finalStatus); + } + + /** + * Handle a 3xx redirect status. + */ + private void handleRedirect(State state, HttpResponse response, int statusCode) + throws StopRequest, RetryDownload { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "got HTTP redirect " + statusCode); + } + if (state.mRedirectCount >= Constants.MAX_REDIRECTS) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "too many redirects for download " + mInfo.mId + + " at " + mInfo.mUri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "too many redirects for download " + mInfo.mId); + } + throw new StopRequest(Downloads.Impl.STATUS_TOO_MANY_REDIRECTS); + } + Header header = response.getFirstHeader("Location"); + if (header == null) { + return; + } + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Location :" + header.getValue()); + } + + String newUri; + try { + newUri = new URI(mInfo.mUri).resolve(new URI(header.getValue())).toString(); + } catch(URISyntaxException ex) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "Couldn't resolve redirect URI " + header.getValue() + + " for " + mInfo.mUri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, + "Couldn't resolve redirect URI for download " + + mInfo.mId); + } + throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR); + } + ++state.mRedirectCount; + state.mRequestUri = newUri; + if (statusCode == 301 || statusCode == 303) { + // use the new URI for all future requests (should a retry/resume be necessary) + state.mNewUri = newUri; + } + throw new RetryDownload(); + } + + /** + * Handle a 503 Service Unavailable status by processing the Retry-After header. + */ + private void handleServiceUnavailable(State state, HttpResponse response) throws StopRequest { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "got HTTP response code 503"); + } + state.mCountRetry = true; + Header header = response.getFirstHeader("Retry-After"); + if (header != null) { + try { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Retry-After :" + header.getValue()); + } + state.mRetryAfter = Integer.parseInt(header.getValue()); + if (state.mRetryAfter < 0) { + state.mRetryAfter = 0; + } else { + if (state.mRetryAfter < Constants.MIN_RETRY_AFTER) { + state.mRetryAfter = Constants.MIN_RETRY_AFTER; + } else if (state.mRetryAfter > Constants.MAX_RETRY_AFTER) { + state.mRetryAfter = Constants.MAX_RETRY_AFTER; + } + state.mRetryAfter += Helpers.sRandom.nextInt(Constants.MIN_RETRY_AFTER + 1); + state.mRetryAfter *= 1000; + } + } catch (NumberFormatException ex) { + // ignored - retryAfter stays 0 in this case. + } + } + throw new StopRequest(Downloads.Impl.STATUS_RUNNING_PAUSED); + } + + /** + * Send the request to the server, handling any I/O exceptions. + */ + private HttpResponse sendRequest(State state, AndroidHttpClient client, HttpGet request) + throws StopRequest { + try { + return client.execute(request); + } catch (IllegalArgumentException ex) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "Arg exception trying to execute request for " + + mInfo.mUri + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "Arg exception trying to execute request for " + + mInfo.mId + " : " + ex); + } + throw new StopRequest(Downloads.Impl.STATUS_HTTP_DATA_ERROR, ex); + } catch (IOException ex) { + logNetworkState(); + throw new StopRequest(handleHttpError(state, "IOException trying to execute request"), + ex); + } + } + + /** + * @return the final status for this attempt + */ + private int handleHttpError(State state, String message) { + if (Constants.LOGV) { + Log.d(Constants.TAG, message + " for " + mInfo.mUri); + } + + if (!Helpers.isNetworkAvailable(mSystemFacade)) { + return Downloads.Impl.STATUS_RUNNING_PAUSED; + } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { + state.mCountRetry = true; + return Downloads.Impl.STATUS_RUNNING_PAUSED; + } else { + Log.d(Constants.TAG, "reached max retries: " + message + " for " + mInfo.mId); + return Downloads.Impl.STATUS_HTTP_DATA_ERROR; + } + } + + /** + * Prepare the destination file to receive data. If the file already exists, we'll set up + * appropriately for resumption. + */ + private void setupDestinationFile(State state, InnerState innerState) + throws StopRequest, FileNotFoundException { + if (state.mFilename != null) { // only true if we've already run a thread for this download + if (!Helpers.isFilenameValid(state.mFilename)) { + throw new StopRequest(Downloads.Impl.STATUS_FILE_ERROR); + } + // We're resuming a download that got interrupted + File f = new File(state.mFilename); + if (f.exists()) { + long fileLength = f.length(); + if (fileLength == 0) { + // The download hadn't actually started, we can restart from scratch + f.delete(); + state.mFilename = null; + } else if (mInfo.mETag == null && !mInfo.mNoIntegrity) { + // This should've been caught upon failure + Log.wtf(Constants.TAG, "Trying to resume a download that can't be resumed"); + f.delete(); + throw new StopRequest(Downloads.Impl.STATUS_CANNOT_RESUME); + } else { + // All right, we'll be able to resume this download + state.mStream = new FileOutputStream(state.mFilename, true); + innerState.mBytesSoFar = (int) fileLength; + if (mInfo.mTotalBytes != -1) { + innerState.mHeaderContentLength = Long.toString(mInfo.mTotalBytes); } + innerState.mHeaderETag = mInfo.mETag; + innerState.mContinuingDownload = true; } } - notifyDownloadCompleted(finalStatus, countRetry, retryAfter, redirectCount, - gotData, filename, newUri, mimeType); + } + + if (state.mStream != null && mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL + && !isDrmFile(state)) { + closeDestination(state); + } + } + + /** + * Add custom headers for this download to the HTTP request. + */ + private void addRequestHeaders(InnerState innerState, HttpGet request) { + for (Map.Entry<String, String> header : mInfo.getHeaders().entrySet()) { + request.addHeader(header.getKey(), header.getValue()); + } + + if (innerState.mContinuingDownload) { + if (innerState.mHeaderETag != null) { + request.addHeader("If-Match", innerState.mHeaderETag); + } + request.addHeader("Range", "bytes=" + innerState.mBytesSoFar + "-"); } } @@ -754,7 +889,7 @@ http_request_loop: values.put(Downloads.Impl.COLUMN_URI, uri); } values.put(Downloads.Impl.COLUMN_MIME_TYPE, mimeType); - values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, System.currentTimeMillis()); + values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, mSystemFacade.currentTimeMillis()); values.put(Constants.RETRY_AFTER_X_REDIRECT_COUNT, retryAfter + (redirectCount << 28)); if (!countRetry) { values.put(Constants.FAILED_CONNECTIONS, 0); @@ -774,7 +909,7 @@ http_request_loop: */ private void notifyThroughIntent() { Uri uri = Uri.parse(Downloads.Impl.CONTENT_URI + "/" + mInfo.mId); - mInfo.sendIntentIfRequested(uri, mContext); + mInfo.sendIntentIfRequested(uri); } /** @@ -784,7 +919,7 @@ http_request_loop: * @return null if mimeType was null. Otherwise a string which represents a * single mimetype in lowercase and with surrounding whitespaces trimmed. */ - private String sanitizeMimeType(String mimeType) { + private static String sanitizeMimeType(String mimeType) { try { mimeType = mimeType.trim().toLowerCase(Locale.ENGLISH); diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java index 0c256a75..794bb062 100644 --- a/src/com/android/providers/downloads/Helpers.java +++ b/src/com/android/providers/downloads/Helpers.java @@ -23,19 +23,16 @@ import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.database.Cursor; import android.drm.mobile1.DrmRawContent; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; import android.net.Uri; import android.os.Environment; import android.os.StatFs; import android.os.SystemClock; import android.provider.Downloads; -import android.telephony.TelephonyManager; import android.util.Config; import android.util.Log; import android.webkit.MimeTypeMap; -import java.io.File; +import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.util.Random; @@ -76,6 +73,17 @@ public class Helpers { } /** + * Exception thrown from methods called by generateSaveFile() for any fatal error. + */ + private static class GenerateSaveFileError extends Exception { + int mStatus; + + public GenerateSaveFileError(int status) { + mStatus = status; + } + } + + /** * Creates a filename (where the file should be saved) from a uri. */ public static DownloadFileInfo generateSaveFile( @@ -86,18 +94,82 @@ public class Helpers { String contentLocation, String mimeType, int destination, - int contentLength) throws FileNotFoundException { + long contentLength, + boolean isPublicApi) throws FileNotFoundException { + + if (!canHandleDownload(context, mimeType, destination, isPublicApi)) { + return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_NOT_ACCEPTABLE); + } + + String fullFilename; + try { + if (destination == Downloads.Impl.DESTINATION_FILE_URI) { + fullFilename = getPathForFileUri(hint); + } else { + fullFilename = chooseFullPath(context, url, hint, contentDisposition, + contentLocation, mimeType, destination, + contentLength); + } + } catch (GenerateSaveFileError exc) { + return new DownloadFileInfo(null, null, exc.mStatus); + } + + return new DownloadFileInfo(fullFilename, new FileOutputStream(fullFilename), 0); + } + + private static String getPathForFileUri(String hint) throws GenerateSaveFileError { + String path = Uri.parse(hint).getSchemeSpecificPart(); + if (new File(path).exists()) { + Log.d(Constants.TAG, "File already exists: " + path); + throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); + } + + return 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); + + // Split filename between base and extension + // Add an extension if filename does not have one + String extension = null; + int dotIndex = filename.indexOf('.'); + if (dotIndex < 0) { + extension = chooseExtensionFromMimeType(mimeType, true); + } else { + extension = chooseExtensionFromFilename(mimeType, destination, filename, dotIndex); + filename = filename.substring(0, dotIndex); + } + + boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension); + + filename = base.getPath() + File.separator + filename; + + if (Constants.LOGVV) { + Log.v(Constants.TAG, "target file: " + filename + extension); + } + + return chooseUniqueFilename(destination, filename, extension, recoveryDir); + } + + private static boolean canHandleDownload(Context context, String mimeType, int destination, + boolean isPublicApi) { + if (isPublicApi) { + return true; + } - /* - * Don't download files that we won't be able to handle - */ if (destination == Downloads.Impl.DESTINATION_EXTERNAL || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) { if (mimeType == null) { if (Config.LOGD) { Log.d(Constants.TAG, "external download with no mime type not allowed"); } - return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_NOT_ACCEPTABLE); + return false; } if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { // Check to see if we are allowed to download this file. Only files @@ -121,32 +193,19 @@ public class Helpers { if (Config.LOGD) { Log.d(Constants.TAG, "no handler found for type " + mimeType); } - return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_NOT_ACCEPTABLE); + return false; } } } - String filename = chooseFilename( - url, hint, contentDisposition, contentLocation, destination); - - // Split filename between base and extension - // Add an extension if filename does not have one - String extension = null; - int dotIndex = filename.indexOf('.'); - if (dotIndex < 0) { - extension = chooseExtensionFromMimeType(mimeType, true); - } else { - extension = chooseExtensionFromFilename( - mimeType, destination, filename, dotIndex); - filename = filename.substring(0, dotIndex); - } - - /* - * Locate the directory where the file will be saved - */ + return true; + } + private static File locateDestinationDirectory(Context context, String mimeType, + int destination, long contentLength) + throws GenerateSaveFileError { File base = null; StatFs stat = null; - // DRM messages should be temporarily stored internally and then passed to + // 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 @@ -170,8 +229,7 @@ public class Helpers { Log.d(Constants.TAG, "download aborted - not enough free space in internal storage"); } - return new DownloadFileInfo(null, null, - Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); + throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); } else { // Recalculate available space and try again. stat.restat(base.getPath()); @@ -192,8 +250,7 @@ public class Helpers { if (Config.LOGD) { Log.d(Constants.TAG, "download aborted - not enough free space"); } - return new DownloadFileInfo(null, null, - Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); + throw new GenerateSaveFileError(Downloads.Impl.STATUS_INSUFFICIENT_SPACE_ERROR); } base = new File(root + Constants.DEFAULT_DL_SUBDIR); @@ -204,35 +261,17 @@ public class Helpers { Log.d(Constants.TAG, "download aborted - can't create base directory " + base.getPath()); } - return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_FILE_ERROR); + throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); } } else { // No SD card found. if (Config.LOGD) { Log.d(Constants.TAG, "download aborted - no external storage"); } - return new DownloadFileInfo(null, null, - Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR); + throw new GenerateSaveFileError(Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR); } - boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension); - - filename = base.getPath() + File.separator + filename; - - /* - * Generate a unique filename, create the file, return it. - */ - if (Constants.LOGVV) { - Log.v(Constants.TAG, "target file: " + filename + extension); - } - - String fullFilename = chooseUniqueFilename( - destination, filename, extension, recoveryDir); - if (fullFilename != null) { - return new DownloadFileInfo(fullFilename, new FileOutputStream(fullFilename), 0); - } else { - return new DownloadFileInfo(null, null, Downloads.Impl.STATUS_FILE_ERROR); - } + return base; } private static String chooseFilename(String url, String hint, String contentDisposition, @@ -384,7 +423,7 @@ public class Helpers { } private static String chooseUniqueFilename(int destination, String filename, - String extension, boolean recoveryDir) { + String extension, boolean recoveryDir) throws GenerateSaveFileError { String fullFilename = filename + extension; if (!new File(fullFilename).exists() && (!recoveryDir || @@ -421,7 +460,7 @@ public class Helpers { sequence += sRandom.nextInt(magnitude) + 1; } } - return null; + throw new GenerateSaveFileError(Downloads.Impl.STATUS_FILE_ERROR); } /** @@ -473,68 +512,17 @@ public class Helpers { /** * Returns whether the network is available */ - public static boolean isNetworkAvailable(Context context) { - ConnectivityManager connectivity = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivity == null) { - Log.w(Constants.TAG, "couldn't get connectivity manager"); - } else { - NetworkInfo[] info = connectivity.getAllNetworkInfo(); - if (info != null) { - for (int i = 0; i < info.length; i++) { - if (info[i].getState() == NetworkInfo.State.CONNECTED) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "network is available"); - } - return true; - } - } - } - } - if (Constants.LOGVV) { - Log.v(Constants.TAG, "network is not available"); - } - return false; - } - - /** - * Returns whether the network is roaming - */ - public static boolean isNetworkRoaming(Context context) { - ConnectivityManager connectivity = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivity == null) { - Log.w(Constants.TAG, "couldn't get connectivity manager"); - } else { - NetworkInfo info = connectivity.getActiveNetworkInfo(); - if (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE) { - if (TelephonyManager.getDefault().isNetworkRoaming()) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "network is roaming"); - } - return true; - } else { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "network is not roaming"); - } - } - } else { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "not using mobile network"); - } - } - } - return false; + public static boolean isNetworkAvailable(SystemFacade system) { + return system.getActiveNetworkType() != null; } /** * Checks whether the filename looks legitimate */ public static boolean isFilenameValid(String filename) { - File dir = new File(filename).getParentFile(); - return dir.equals(Environment.getDownloadCacheDirectory()) - || dir.equals(new File(Environment.getExternalStorageDirectory() - + Constants.DEFAULT_DL_SUBDIR)); + filename = filename.replaceFirst("/+", "/"); // normalize leading slashes + return filename.startsWith(Environment.getDownloadCacheDirectory().toString()) + || filename.startsWith(Environment.getExternalStorageDirectory().toString()); } /** @@ -781,7 +769,7 @@ public class Helpers { } // anything we don't recognize - throw new IllegalArgumentException("illegal character"); + throw new IllegalArgumentException("illegal character: " + chars[mOffset]); } private static final boolean isIdentifierStart(char c) { diff --git a/src/com/android/providers/downloads/RealSystemFacade.java b/src/com/android/providers/downloads/RealSystemFacade.java new file mode 100644 index 00000000..710da10d --- /dev/null +++ b/src/com/android/providers/downloads/RealSystemFacade.java @@ -0,0 +1,102 @@ +package com.android.providers.downloads; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager.NameNotFoundException; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.provider.Settings; +import android.provider.Settings.SettingNotFoundException; +import android.telephony.TelephonyManager; +import android.util.Log; + +class RealSystemFacade implements SystemFacade { + private Context mContext; + private NotificationManager mNotificationManager; + + public RealSystemFacade(Context context) { + mContext = context; + mNotificationManager = (NotificationManager) + mContext.getSystemService(Context.NOTIFICATION_SERVICE); + } + + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + public Integer getActiveNetworkType() { + ConnectivityManager connectivity = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivity == null) { + Log.w(Constants.TAG, "couldn't get connectivity manager"); + return null; + } + + NetworkInfo activeInfo = connectivity.getActiveNetworkInfo(); + if (activeInfo == null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "network is not available"); + } + return null; + } + return activeInfo.getType(); + } + + public boolean isNetworkRoaming() { + ConnectivityManager connectivity = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivity == null) { + Log.w(Constants.TAG, "couldn't get connectivity manager"); + return false; + } + + NetworkInfo info = connectivity.getActiveNetworkInfo(); + boolean isMobile = (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE); + boolean isRoaming = isMobile && TelephonyManager.getDefault().isNetworkRoaming(); + if (Constants.LOGVV && isRoaming) { + Log.v(Constants.TAG, "network is roaming"); + } + return isRoaming; + } + + public Long getMaxBytesOverMobile() { + try { + return Settings.Secure.getLong(mContext.getContentResolver(), + Settings.Secure.DOWNLOAD_MAX_BYTES_OVER_MOBILE); + } catch (SettingNotFoundException exc) { + return null; + } + } + + @Override + public void sendBroadcast(Intent intent) { + mContext.sendBroadcast(intent); + } + + @Override + public boolean userOwnsPackage(int uid, String packageName) throws NameNotFoundException { + return mContext.getPackageManager().getApplicationInfo(packageName, 0).uid == uid; + } + + @Override + public void postNotification(int id, Notification notification) { + mNotificationManager.notify(id, notification); + } + + @Override + public void cancelNotification(int id) { + mNotificationManager.cancel(id); + } + + @Override + public void cancelAllNotifications() { + mNotificationManager.cancelAll(); + } + + @Override + public void startThread(Thread thread) { + thread.start(); + } +} diff --git a/src/com/android/providers/downloads/SystemFacade.java b/src/com/android/providers/downloads/SystemFacade.java new file mode 100644 index 00000000..c1941692 --- /dev/null +++ b/src/com/android/providers/downloads/SystemFacade.java @@ -0,0 +1,61 @@ + +package com.android.providers.downloads; + +import android.app.Notification; +import android.content.Intent; +import android.content.pm.PackageManager.NameNotFoundException; + + +interface SystemFacade { + /** + * @see System#currentTimeMillis() + */ + public long currentTimeMillis(); + + /** + * @return Network type (as in ConnectivityManager.TYPE_*) of currently active network, or null + * if there's no active connection. + */ + public Integer getActiveNetworkType(); + + /** + * @see android.telephony.TelephonyManager#isNetworkRoaming + */ + public boolean isNetworkRoaming(); + + /** + * @return maximum size, in bytes, of downloads that may go over a mobile connection; or null if + * there's no limit + */ + public Long getMaxBytesOverMobile(); + + /** + * Send a broadcast intent. + */ + public void sendBroadcast(Intent intent); + + /** + * Returns true if the specified UID owns the specified package name. + */ + public boolean userOwnsPackage(int uid, String pckg) throws NameNotFoundException; + + /** + * Post a system notification to the NotificationManager. + */ + public void postNotification(int id, Notification notification); + + /** + * Cancel a system notification. + */ + public void cancelNotification(int id); + + /** + * Cancel all system notifications. + */ + public void cancelAllNotifications(); + + /** + * Start a thread. + */ + public void startThread(Thread thread); +} |