diff options
39 files changed, 1551 insertions, 1565 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7a1ce39e..3024a17a 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -57,6 +57,7 @@ <application android:process="android.process.media" android:label="@string/app_label"> + <provider android:name=".DownloadProvider" android:authorities="downloads" android:exported="true"> <!-- Anyone can access /my_downloads, the provider internally restricts access by UID for @@ -72,6 +73,9 @@ <!-- Apps with access to /all_downloads/... can grant permissions, allowing them to share downloaded files with other viewers --> <grant-uri-permission android:pathPrefix="/all_downloads/"/> + <!-- Apps with access to /my_downloads/... can grant permissions, allowing them to share + downloaded files with other viewers --> + <grant-uri-permission android:pathPrefix="/my_downloads/"/> </provider> <service android:name=".DownloadService" android:permission="android.permission.ACCESS_DOWNLOAD_MANAGER" /> diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java index 8d806182..08ef4665 100644 --- a/src/com/android/providers/downloads/Constants.java +++ b/src/com/android/providers/downloads/Constants.java @@ -48,9 +48,6 @@ public class Constants { /** The column that is used to remember whether the media scanner was invoked */ public static final String MEDIA_SCANNED = "scanned"; - /** The column that is used to count retries */ - public static final String FAILED_CONNECTIONS = "numfailed"; - /** The intent that gets sent when the service must wake up for a retry */ public static final String ACTION_RETRY = "android.intent.action.DOWNLOAD_WAKEUP"; diff --git a/src/com/android/providers/downloads/DownloadDrmHelper.java b/src/com/android/providers/downloads/DownloadDrmHelper.java index 10cb792c..d1358246 100644 --- a/src/com/android/providers/downloads/DownloadDrmHelper.java +++ b/src/com/android/providers/downloads/DownloadDrmHelper.java @@ -19,7 +19,8 @@ package com.android.providers.downloads; import android.content.Context; import android.drm.DrmManagerClient; -import android.util.Log; + +import java.io.File; public class DownloadDrmHelper { @@ -32,31 +33,6 @@ public class DownloadDrmHelper { public static final String EXTENSION_INTERNAL_FWDL = ".fl"; /** - * Checks if the Media Type is a DRM Media Type - * - * @param drmManagerClient A DrmManagerClient - * @param mimetype Media Type to check - * @return True if the Media Type is DRM else false - */ - public static boolean isDrmMimeType(Context context, String mimetype) { - boolean result = false; - if (context != null) { - try { - DrmManagerClient drmClient = new DrmManagerClient(context); - if (drmClient != null && mimetype != null && mimetype.length() > 0) { - result = drmClient.canHandle("", mimetype); - } - } catch (IllegalArgumentException e) { - Log.w(Constants.TAG, - "DrmManagerClient instance could not be created, context is Illegal."); - } catch (IllegalStateException e) { - Log.w(Constants.TAG, "DrmManagerClient didn't initialize properly."); - } - } - return result; - } - - /** * Checks if the Media Type needs to be DRM converted * * @param mimetype Media type of the content @@ -83,28 +59,20 @@ public class DownloadDrmHelper { } /** - * Gets the original mime type of DRM protected content. - * - * @param context The context - * @param path Path to the file - * @param containingMime The current mime type of of the file i.e. the - * containing mime type - * @return The original mime type of the file if DRM protected else the - * currentMime + * Return the original MIME type of the given file, using the DRM framework + * if the file is protected content. */ - public static String getOriginalMimeType(Context context, String path, String containingMime) { - String result = containingMime; - DrmManagerClient drmClient = new DrmManagerClient(context); + public static String getOriginalMimeType(Context context, File file, String currentMime) { + final DrmManagerClient client = new DrmManagerClient(context); try { - if (drmClient.canHandle(path, null)) { - result = drmClient.getOriginalMimeType(path); + final String rawFile = file.toString(); + if (client.canHandle(rawFile, null)) { + return client.getOriginalMimeType(rawFile); + } else { + return currentMime; } - } catch (IllegalArgumentException ex) { - Log.w(Constants.TAG, - "Can't get original mime type since path is null or empty string."); - } catch (IllegalStateException ex) { - Log.w(Constants.TAG, "DrmManagerClient didn't initialize properly."); + } finally { + client.release(); } - return result; } } diff --git a/src/com/android/providers/downloads/DownloadHandler.java b/src/com/android/providers/downloads/DownloadHandler.java deleted file mode 100644 index 2f02864e..00000000 --- a/src/com/android/providers/downloads/DownloadHandler.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.providers.downloads; - -import android.content.res.Resources; -import android.util.Log; -import android.util.LongSparseArray; - -import com.android.internal.annotations.GuardedBy; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedHashMap; - -public class DownloadHandler { - private static final String TAG = "DownloadHandler"; - - @GuardedBy("this") - private final LinkedHashMap<Long, DownloadInfo> mDownloadsQueue = - new LinkedHashMap<Long, DownloadInfo>(); - @GuardedBy("this") - private final HashMap<Long, DownloadInfo> mDownloadsInProgress = - new HashMap<Long, DownloadInfo>(); - @GuardedBy("this") - private final LongSparseArray<Long> mCurrentSpeed = new LongSparseArray<Long>(); - - private final int mMaxConcurrentDownloadsAllowed = Resources.getSystem().getInteger( - com.android.internal.R.integer.config_MaxConcurrentDownloadsAllowed); - - private static final DownloadHandler sDownloadHandler = new DownloadHandler(); - - public static DownloadHandler getInstance() { - return sDownloadHandler; - } - - public synchronized void enqueueDownload(DownloadInfo info) { - if (!mDownloadsQueue.containsKey(info.mId)) { - if (Constants.LOGV) { - Log.i(TAG, "enqueued download. id: " + info.mId + ", uri: " + info.mUri); - } - mDownloadsQueue.put(info.mId, info); - startDownloadThreadLocked(); - } - } - - private void startDownloadThreadLocked() { - Iterator<Long> keys = mDownloadsQueue.keySet().iterator(); - ArrayList<Long> ids = new ArrayList<Long>(); - while (mDownloadsInProgress.size() < mMaxConcurrentDownloadsAllowed && keys.hasNext()) { - Long id = keys.next(); - DownloadInfo info = mDownloadsQueue.get(id); - info.startDownloadThread(); - ids.add(id); - mDownloadsInProgress.put(id, mDownloadsQueue.get(id)); - if (Constants.LOGV) { - Log.i(TAG, "started download for : " + id); - } - } - for (Long id : ids) { - mDownloadsQueue.remove(id); - } - } - - public synchronized boolean hasDownloadInQueue(long id) { - return mDownloadsQueue.containsKey(id) || mDownloadsInProgress.containsKey(id); - } - - public synchronized void dequeueDownload(long id) { - mDownloadsInProgress.remove(id); - mCurrentSpeed.remove(id); - startDownloadThreadLocked(); - if (mDownloadsInProgress.size() == 0 && mDownloadsQueue.size() == 0) { - notifyAll(); - } - } - - public synchronized void setCurrentSpeed(long id, long speed) { - mCurrentSpeed.put(id, speed); - } - - public synchronized long getCurrentSpeed(long id) { - return mCurrentSpeed.get(id, -1L); - } - - // right now this is only used by tests. but there is no reason why it can't be used - // by any module using DownloadManager (TODO add API to DownloadManager.java) - public synchronized void waitUntilDownloadsTerminate() throws InterruptedException { - if (mDownloadsInProgress.size() == 0 && mDownloadsQueue.size() == 0) { - if (Constants.LOGVV) { - Log.i(TAG, "nothing to wait on"); - } - return; - } - if (Constants.LOGVV) { - for (DownloadInfo info : mDownloadsInProgress.values()) { - Log.i(TAG, "** progress: " + info.mId + ", " + info.mUri); - } - for (DownloadInfo info : mDownloadsQueue.values()) { - Log.i(TAG, "** in Q: " + info.mId + ", " + info.mUri); - } - } - if (Constants.LOGVV) { - Log.i(TAG, "waiting for 5 sec"); - } - // wait upto 5 sec - wait(5 * 1000); - } -} diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java index 5172b696..7a912d5a 100644 --- a/src/com/android/providers/downloads/DownloadInfo.java +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -31,20 +31,26 @@ import android.os.Environment; import android.provider.Downloads; import android.provider.Downloads.Impl; import android.text.TextUtils; -import android.util.Log; import android.util.Pair; +import com.android.internal.annotations.GuardedBy; import com.android.internal.util.IndentingPrintWriter; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; /** * Stores information about an individual download. */ public class DownloadInfo { + // TODO: move towards these in-memory objects being sources of truth, and + // periodically pushing to provider. + public static class Reader { private ContentResolver mResolver; private Cursor mCursor; @@ -54,8 +60,10 @@ public class DownloadInfo { mCursor = cursor; } - public DownloadInfo newDownloadInfo(Context context, SystemFacade systemFacade) { - DownloadInfo info = new DownloadInfo(context, systemFacade); + public DownloadInfo newDownloadInfo(Context context, SystemFacade systemFacade, + StorageManager storageManager, DownloadNotifier notifier) { + final DownloadInfo info = new DownloadInfo( + context, systemFacade, storageManager, notifier); updateFromDatabase(info); readRequestHeaders(info); return info; @@ -71,7 +79,7 @@ public class DownloadInfo { info.mDestination = getInt(Downloads.Impl.COLUMN_DESTINATION); info.mVisibility = getInt(Downloads.Impl.COLUMN_VISIBILITY); info.mStatus = getInt(Downloads.Impl.COLUMN_STATUS); - info.mNumFailed = getInt(Constants.FAILED_CONNECTIONS); + info.mNumFailed = getInt(Downloads.Impl.COLUMN_FAILED_CONNECTIONS); int retryRedirect = getInt(Constants.RETRY_AFTER_X_REDIRECT_COUNT); info.mRetryAfter = retryRedirect & 0xfffffff; info.mLastMod = getLong(Downloads.Impl.COLUMN_LAST_MODIFICATION); @@ -146,44 +154,49 @@ public class DownloadInfo { } } - // the following NETWORK_* constants are used to indicates specfic reasons for disallowing a - // download from using a network, since specific causes can require special handling - - /** - * The network is usable for the given download. - */ - public static final int NETWORK_OK = 1; - - /** - * There is no network connectivity. - */ - public static final int NETWORK_NO_CONNECTION = 2; - - /** - * The download exceeds the maximum size for this network. - */ - public static final int NETWORK_UNUSABLE_DUE_TO_SIZE = 3; - - /** - * The download exceeds the recommended maximum size for this network, the user must confirm for - * this download to proceed without WiFi. - */ - public static final int NETWORK_RECOMMENDED_UNUSABLE_DUE_TO_SIZE = 4; - /** - * The current connection is roaming, and the download can't proceed over a roaming connection. + * Constants used to indicate network state for a specific download, after + * applying any requested constraints. */ - public static final int NETWORK_CANNOT_USE_ROAMING = 5; - - /** - * The app requesting the download specific that it can't use the current network connection. - */ - public static final int NETWORK_TYPE_DISALLOWED_BY_REQUESTOR = 6; - - /** - * Current network is blocked for requesting application. - */ - public static final int NETWORK_BLOCKED = 7; + public enum NetworkState { + /** + * The network is usable for the given download. + */ + OK, + + /** + * There is no network connectivity. + */ + NO_CONNECTION, + + /** + * The download exceeds the maximum size for this network. + */ + UNUSABLE_DUE_TO_SIZE, + + /** + * The download exceeds the recommended maximum size for this network, + * the user must confirm for this download to proceed without WiFi. + */ + RECOMMENDED_UNUSABLE_DUE_TO_SIZE, + + /** + * The current connection is roaming, and the download can't proceed + * over a roaming connection. + */ + CANNOT_USE_ROAMING, + + /** + * The app requesting the download specific that it can't use the + * current network connection. + */ + TYPE_DISALLOWED_BY_REQUESTOR, + + /** + * Current network is blocked for requesting application. + */ + BLOCKED; + } /** * For intents used to notify the user that a download exceeds a size threshold, if this extra @@ -191,7 +204,6 @@ public class DownloadInfo { */ public static final String EXTRA_IS_WIFI_REQUIRED = "isWifiRequired"; - public long mId; public String mUri; public boolean mNoIntegrity; @@ -229,12 +241,28 @@ public class DownloadInfo { public int mFuzz; private List<Pair<String, String>> mRequestHeaders = new ArrayList<Pair<String, String>>(); - private SystemFacade mSystemFacade; - private Context mContext; - private DownloadInfo(Context context, SystemFacade systemFacade) { + /** + * Result of last {@link DownloadThread} started by + * {@link #startDownloadIfReady(ExecutorService)}. + */ + @GuardedBy("this") + private Future<?> mSubmittedTask; + + @GuardedBy("this") + private DownloadThread mTask; + + private final Context mContext; + private final SystemFacade mSystemFacade; + private final StorageManager mStorageManager; + private final DownloadNotifier mNotifier; + + private DownloadInfo(Context context, SystemFacade systemFacade, StorageManager storageManager, + DownloadNotifier notifier) { mContext = context; mSystemFacade = systemFacade; + mStorageManager = storageManager; + mNotifier = notifier; mFuzz = Helpers.sRandom.nextInt(1001); } @@ -285,14 +313,9 @@ public class DownloadInfo { } /** - * Returns whether this download (which the download manager hasn't seen yet) - * should be started. + * Returns whether this download should be enqueued. */ - private boolean isReadyToStart(long now) { - if (DownloadHandler.getInstance().hasDownloadInQueue(mId)) { - // already running - return false; - } + private boolean isReadyToDownload() { if (mControl == Downloads.Impl.CONTROL_PAUSED) { // the download is paused, so it's not going to start return false; @@ -306,10 +329,11 @@ public class DownloadInfo { case Downloads.Impl.STATUS_WAITING_FOR_NETWORK: case Downloads.Impl.STATUS_QUEUED_FOR_WIFI: - return checkCanUseNetwork() == NETWORK_OK; + return checkCanUseNetwork() == NetworkState.OK; case Downloads.Impl.STATUS_WAITING_TO_RETRY: // download was waiting for a delayed restart + final long now = mSystemFacade.currentTimeMillis(); return restartTime(now) <= now; case Downloads.Impl.STATUS_DEVICE_NOT_FOUND_ERROR: // is the media mounted? @@ -337,21 +361,20 @@ public class DownloadInfo { /** * Returns whether this download is allowed to use the network. - * @return one of the NETWORK_* constants */ - public int checkCanUseNetwork() { + public NetworkState checkCanUseNetwork() { final NetworkInfo info = mSystemFacade.getActiveNetworkInfo(mUid); if (info == null || !info.isConnected()) { - return NETWORK_NO_CONNECTION; + return NetworkState.NO_CONNECTION; } if (DetailedState.BLOCKED.equals(info.getDetailedState())) { - return NETWORK_BLOCKED; + return NetworkState.BLOCKED; } - if (!isRoamingAllowed() && mSystemFacade.isNetworkRoaming()) { - return NETWORK_CANNOT_USE_ROAMING; + if (mSystemFacade.isNetworkRoaming() && !isRoamingAllowed()) { + return NetworkState.CANNOT_USE_ROAMING; } - if (!mAllowMetered && mSystemFacade.isActiveNetworkMetered()) { - return NETWORK_TYPE_DISALLOWED_BY_REQUESTOR; + if (mSystemFacade.isActiveNetworkMetered() && !mAllowMetered) { + return NetworkState.TYPE_DISALLOWED_BY_REQUESTOR; } return checkIsNetworkTypeAllowed(info.getType()); } @@ -365,45 +388,16 @@ public class DownloadInfo { } /** - * @return a non-localized string appropriate for logging corresponding to one of the - * NETWORK_* constants. - */ - public String getLogMessageForNetworkError(int networkError) { - switch (networkError) { - case NETWORK_RECOMMENDED_UNUSABLE_DUE_TO_SIZE: - return "download size exceeds recommended limit for mobile network"; - - case NETWORK_UNUSABLE_DUE_TO_SIZE: - return "download size exceeds limit for mobile network"; - - case NETWORK_NO_CONNECTION: - return "no network connection available"; - - case NETWORK_CANNOT_USE_ROAMING: - return "download cannot use the current network connection because it is roaming"; - - case NETWORK_TYPE_DISALLOWED_BY_REQUESTOR: - return "download was requested to not use the current network type"; - - case NETWORK_BLOCKED: - return "network is blocked for requesting application"; - - default: - return "unknown error with network connectivity"; - } - } - - /** * Check if this download can proceed over the given network type. * @param networkType a constant from ConnectivityManager.TYPE_*. * @return one of the NETWORK_* constants */ - private int checkIsNetworkTypeAllowed(int networkType) { + private NetworkState checkIsNetworkTypeAllowed(int networkType) { if (mIsPublicApi) { final int flag = translateNetworkTypeToApiFlag(networkType); final boolean allowAllNetworkTypes = mAllowedNetworkTypes == ~0; if (!allowAllNetworkTypes && (flag & mAllowedNetworkTypes) == 0) { - return NETWORK_TYPE_DISALLOWED_BY_REQUESTOR; + return NetworkState.TYPE_DISALLOWED_BY_REQUESTOR; } } return checkSizeAllowedForNetwork(networkType); @@ -433,42 +427,68 @@ public class DownloadInfo { * Check if the download's size prohibits it from running over the current network. * @return one of the NETWORK_* constants */ - private int checkSizeAllowedForNetwork(int networkType) { + private NetworkState checkSizeAllowedForNetwork(int networkType) { if (mTotalBytes <= 0) { - return NETWORK_OK; // we don't know the size yet + return NetworkState.OK; // we don't know the size yet } if (networkType == ConnectivityManager.TYPE_WIFI) { - return NETWORK_OK; // anything goes over wifi + return NetworkState.OK; // anything goes over wifi } Long maxBytesOverMobile = mSystemFacade.getMaxBytesOverMobile(); if (maxBytesOverMobile != null && mTotalBytes > maxBytesOverMobile) { - return NETWORK_UNUSABLE_DUE_TO_SIZE; + return NetworkState.UNUSABLE_DUE_TO_SIZE; } if (mBypassRecommendedSizeLimit == 0) { Long recommendedMaxBytesOverMobile = mSystemFacade.getRecommendedMaxBytesOverMobile(); if (recommendedMaxBytesOverMobile != null && mTotalBytes > recommendedMaxBytesOverMobile) { - return NETWORK_RECOMMENDED_UNUSABLE_DUE_TO_SIZE; + return NetworkState.RECOMMENDED_UNUSABLE_DUE_TO_SIZE; } } - return NETWORK_OK; + return NetworkState.OK; } - void startIfReady(long now, StorageManager storageManager) { - if (!isReadyToStart(now)) { - return; - } + /** + * If download is ready to start, and isn't already pending or executing, + * create a {@link DownloadThread} and enqueue it into given + * {@link Executor}. + * + * @return If actively downloading. + */ + public boolean startDownloadIfReady(ExecutorService executor) { + synchronized (this) { + final boolean isReady = isReadyToDownload(); + final boolean isActive = mSubmittedTask != null && !mSubmittedTask.isDone(); + if (isReady && !isActive) { + if (mStatus != Impl.STATUS_RUNNING) { + mStatus = Impl.STATUS_RUNNING; + ContentValues values = new ContentValues(); + values.put(Impl.COLUMN_STATUS, mStatus); + mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null); + } - if (Constants.LOGV) { - Log.v(Constants.TAG, "Service spawning thread to handle download " + mId); + mTask = new DownloadThread( + mContext, mSystemFacade, this, mStorageManager, mNotifier); + mSubmittedTask = executor.submit(mTask); + } + return isReady; } - if (mStatus != Impl.STATUS_RUNNING) { - mStatus = Impl.STATUS_RUNNING; - ContentValues values = new ContentValues(); - values.put(Impl.COLUMN_STATUS, mStatus); - mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null); + } + + /** + * If download is ready to be scanned, enqueue it into the given + * {@link DownloadScanner}. + * + * @return If actively scanning. + */ + public boolean startScanIfReady(DownloadScanner scanner) { + synchronized (this) { + final boolean isReady = shouldScanFile(); + if (isReady) { + scanner.requestScan(this); + } + return isReady; } - DownloadHandler.getInstance().enqueueDownload(this); } public boolean isOnCache() { @@ -529,15 +549,15 @@ public class DownloadInfo { } /** - * Returns the amount of time (as measured from the "now" parameter) - * at which a download will be active. - * 0 = immediately - service should stick around to handle this download. - * -1 = never - service can go away without ever waking up. - * positive value - service must wake up in the future, as specified in ms from "now" + * Return time when this download will be ready for its next action, in + * milliseconds after given time. + * + * @return If {@code 0}, download is ready to proceed immediately. If + * {@link Long#MAX_VALUE}, then download has no future actions. */ - long nextAction(long now) { + public long nextActionMillis(long now) { if (Downloads.Impl.isStatusCompleted(mStatus)) { - return -1; + return Long.MAX_VALUE; } if (mStatus != Downloads.Impl.STATUS_WAITING_TO_RETRY) { return 0; @@ -552,7 +572,7 @@ public class DownloadInfo { /** * Returns whether a file should be scanned */ - boolean shouldScanFile() { + public boolean shouldScanFile() { return (mMediaScanned == 0) && (mDestination == Downloads.Impl.DESTINATION_EXTERNAL || mDestination == Downloads.Impl.DESTINATION_FILE_URI || @@ -570,12 +590,6 @@ public class DownloadInfo { mContext.startActivity(intent); } - void startDownloadThread() { - DownloadThread downloader = new DownloadThread(mContext, mSystemFacade, this, - StorageManager.getInstance(mContext)); - mSystemFacade.startThread(downloader); - } - /** * Query and return status of requested download. */ diff --git a/src/com/android/providers/downloads/DownloadNotifier.java b/src/com/android/providers/downloads/DownloadNotifier.java index f3878654..ac52eba2 100644 --- a/src/com/android/providers/downloads/DownloadNotifier.java +++ b/src/com/android/providers/downloads/DownloadNotifier.java @@ -19,6 +19,8 @@ package com.android.providers.downloads; import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE; import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED; import static android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION; +import static android.provider.Downloads.Impl.STATUS_RUNNING; +import static com.android.providers.downloads.Constants.TAG; import android.app.DownloadManager; import android.app.Notification; @@ -29,9 +31,12 @@ import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.net.Uri; +import android.os.SystemClock; import android.provider.Downloads; import android.text.TextUtils; import android.text.format.DateUtils; +import android.util.Log; +import android.util.LongSparseLongArray; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Maps; @@ -66,6 +71,20 @@ public class DownloadNotifier { @GuardedBy("mActiveNotifs") private final HashMap<String, Long> mActiveNotifs = Maps.newHashMap(); + /** + * Current speed of active downloads, mapped from {@link DownloadInfo#mId} + * to speed in bytes per second. + */ + @GuardedBy("mDownloadSpeed") + private final LongSparseLongArray mDownloadSpeed = new LongSparseLongArray(); + + /** + * Last time speed was reproted, mapped from {@link DownloadInfo#mId} to + * {@link SystemClock#elapsedRealtime()}. + */ + @GuardedBy("mDownloadSpeed") + private final LongSparseLongArray mDownloadTouch = new LongSparseLongArray(); + public DownloadNotifier(Context context) { mContext = context; mNotifManager = (NotificationManager) context.getSystemService( @@ -77,6 +96,22 @@ public class DownloadNotifier { } /** + * Notify the current speed of an active download, used for calculating + * estimated remaining time. + */ + public void notifyDownloadSpeed(long id, long bytesPerSecond) { + synchronized (mDownloadSpeed) { + if (bytesPerSecond != 0) { + mDownloadSpeed.put(id, bytesPerSecond); + mDownloadTouch.put(id, SystemClock.elapsedRealtime()); + } else { + mDownloadSpeed.delete(id); + mDownloadTouch.delete(id); + } + } + } + + /** * Update {@link NotificationManager} to reflect the given set of * {@link DownloadInfo}, adding, collapsing, and removing as needed. */ @@ -140,6 +175,7 @@ public class DownloadNotifier { final DownloadInfo info = cluster.iterator().next(); final Uri uri = ContentUris.withAppendedId( Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, info.mId); + builder.setAutoCancel(true); final String action; if (Downloads.Impl.isStatusError(info.mStatus)) { @@ -167,16 +203,16 @@ public class DownloadNotifier { String remainingText = null; String percentText = null; if (type == TYPE_ACTIVE) { - final DownloadHandler handler = DownloadHandler.getInstance(); - long current = 0; long total = 0; long speed = 0; - for (DownloadInfo info : cluster) { - if (info.mTotalBytes != -1) { - current += info.mCurrentBytes; - total += info.mTotalBytes; - speed += handler.getCurrentSpeed(info.mId); + synchronized (mDownloadSpeed) { + for (DownloadInfo info : cluster) { + if (info.mTotalBytes != -1) { + current += info.mCurrentBytes; + total += info.mTotalBytes; + speed += mDownloadSpeed.get(info.mId); + } } } @@ -283,6 +319,17 @@ public class DownloadNotifier { return ids; } + public void dumpSpeeds() { + synchronized (mDownloadSpeed) { + for (int i = 0; i < mDownloadSpeed.size(); i++) { + final long id = mDownloadSpeed.keyAt(i); + final long delta = SystemClock.elapsedRealtime() - mDownloadTouch.get(id); + Log.d(TAG, "Download " + id + " speed " + mDownloadSpeed.valueAt(i) + "bps, " + + delta + "ms ago"); + } + } + } + /** * Build tag used for collapsing several {@link DownloadInfo} into a single * {@link Notification}. @@ -309,7 +356,7 @@ public class DownloadNotifier { } private static boolean isActiveAndVisible(DownloadInfo download) { - return Downloads.Impl.isStatusInformational(download.mStatus) && + return download.mStatus == STATUS_RUNNING && (download.mVisibility == VISIBILITY_VISIBLE || download.mVisibility == VISIBILITY_VISIBLE_NOTIFY_COMPLETED); } diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java index c554e41d..e0b5842d 100644 --- a/src/com/android/providers/downloads/DownloadProvider.java +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -37,17 +37,23 @@ import android.os.Binder; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.os.Process; +import android.os.SELinux; +import android.provider.BaseColumns; import android.provider.Downloads; import android.provider.OpenableColumns; import android.text.TextUtils; +import android.text.format.DateUtils; import android.util.Log; +import com.android.internal.util.IndentingPrintWriter; import com.google.android.collect.Maps; import com.google.common.annotations.VisibleForTesting; import java.io.File; +import java.io.FileDescriptor; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -384,7 +390,7 @@ public final class DownloadProvider extends ContentProvider { Downloads.Impl.COLUMN_VISIBILITY + " INTEGER, " + Downloads.Impl.COLUMN_CONTROL + " INTEGER, " + Downloads.Impl.COLUMN_STATUS + " INTEGER, " + - Constants.FAILED_CONNECTIONS + " INTEGER, " + + Downloads.Impl.COLUMN_FAILED_CONNECTIONS + " INTEGER, " + Downloads.Impl.COLUMN_LAST_MODIFICATION + " BIGINT, " + Downloads.Impl.COLUMN_NOTIFICATION_PACKAGE + " TEXT, " + Downloads.Impl.COLUMN_NOTIFICATION_CLASS + " TEXT, " + @@ -436,8 +442,7 @@ public final class DownloadProvider extends ContentProvider { appInfo = getContext().getPackageManager(). getApplicationInfo("com.android.defcontainer", 0); } catch (NameNotFoundException e) { - // TODO Auto-generated catch block - e.printStackTrace(); + Log.wtf(Constants.TAG, "Could not get ApplicationInfo for com.android.defconatiner", e); } if (appInfo != null) { mDefContainerUid = appInfo.uid; @@ -446,7 +451,12 @@ public final class DownloadProvider extends ContentProvider { // saves us by getting some initialization code in DownloadService out of the way. Context context = getContext(); context.startService(new Intent(context, DownloadService.class)); - mDownloadsDataDir = StorageManager.getInstance(getContext()).getDownloadDataDirectory(); + mDownloadsDataDir = StorageManager.getDownloadDataDirectory(getContext()); + try { + SELinux.restorecon(mDownloadsDataDir.getCanonicalPath()); + } catch (IOException e) { + Log.wtf(Constants.TAG, "Could not get canonical path for download directory", e); + } return true; } @@ -1199,6 +1209,41 @@ public final class DownloadProvider extends ContentProvider { return ret; } + @Override + public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { + final IndentingPrintWriter pw = new IndentingPrintWriter(writer, " ", 120); + + pw.println("Downloads updated in last hour:"); + pw.increaseIndent(); + + final SQLiteDatabase db = mOpenHelper.getReadableDatabase(); + final long modifiedAfter = mSystemFacade.currentTimeMillis() - DateUtils.HOUR_IN_MILLIS; + final Cursor cursor = db.query(DB_TABLE, null, + Downloads.Impl.COLUMN_LAST_MODIFICATION + ">" + modifiedAfter, null, null, null, + Downloads.Impl._ID + " ASC"); + try { + final String[] cols = cursor.getColumnNames(); + final int idCol = cursor.getColumnIndex(BaseColumns._ID); + while (cursor.moveToNext()) { + pw.println("Download #" + cursor.getInt(idCol) + ":"); + pw.increaseIndent(); + for (int i = 0; i < cols.length; i++) { + // Omit sensitive data when dumping + if (Downloads.Impl.COLUMN_COOKIE_DATA.equals(cols[i])) { + continue; + } + pw.printPair(cols[i], cursor.getString(i)); + } + pw.println(); + pw.decreaseIndent(); + } + } finally { + cursor.close(); + } + + pw.decreaseIndent(); + } + private void logVerboseOpenFileInfo(Uri uri, String mode) { Log.v(Constants.TAG, "openFile uri: " + uri + ", mode: " + mode + ", uid: " + Binder.getCallingUid()); @@ -1229,7 +1274,7 @@ public final class DownloadProvider extends ContentProvider { Log.v(Constants.TAG, "file exists in openFile"); } } - cursor.close(); + cursor.close(); } } diff --git a/src/com/android/providers/downloads/DownloadScanner.java b/src/com/android/providers/downloads/DownloadScanner.java new file mode 100644 index 00000000..ca795062 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadScanner.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.downloads; + +import static android.text.format.DateUtils.MINUTE_IN_MILLIS; +import static com.android.providers.downloads.Constants.LOGV; +import static com.android.providers.downloads.Constants.TAG; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.media.MediaScannerConnection; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; +import android.net.Uri; +import android.os.SystemClock; +import android.provider.Downloads; +import android.util.Log; + +import com.android.internal.annotations.GuardedBy; +import com.google.common.collect.Maps; + +import java.util.HashMap; + +/** + * Manages asynchronous scanning of completed downloads. + */ +public class DownloadScanner implements MediaScannerConnectionClient { + private static final long SCAN_TIMEOUT = MINUTE_IN_MILLIS; + + private final Context mContext; + private final MediaScannerConnection mConnection; + + private static class ScanRequest { + public final long id; + public final String path; + public final String mimeType; + public final long requestRealtime; + + public ScanRequest(long id, String path, String mimeType) { + this.id = id; + this.path = path; + this.mimeType = mimeType; + this.requestRealtime = SystemClock.elapsedRealtime(); + } + + public void exec(MediaScannerConnection conn) { + conn.scanFile(path, mimeType); + } + } + + @GuardedBy("mConnection") + private HashMap<String, ScanRequest> mPending = Maps.newHashMap(); + + public DownloadScanner(Context context) { + mContext = context; + mConnection = new MediaScannerConnection(context, this); + } + + /** + * Check if requested scans are still pending. Scans may timeout after an + * internal duration. + */ + public boolean hasPendingScans() { + synchronized (mConnection) { + if (mPending.isEmpty()) { + return false; + } else { + // Check if pending scans have timed out + final long nowRealtime = SystemClock.elapsedRealtime(); + for (ScanRequest req : mPending.values()) { + if (nowRealtime < req.requestRealtime + SCAN_TIMEOUT) { + return true; + } + } + return false; + } + } + } + + /** + * Request that given {@link DownloadInfo} be scanned at some point in + * future. Enqueues the request to be scanned asynchronously. + * + * @see #hasPendingScans() + */ + public void requestScan(DownloadInfo info) { + if (LOGV) Log.v(TAG, "requestScan() for " + info.mFileName); + synchronized (mConnection) { + final ScanRequest req = new ScanRequest(info.mId, info.mFileName, info.mMimeType); + mPending.put(req.path, req); + + if (mConnection.isConnected()) { + req.exec(mConnection); + } else { + mConnection.connect(); + } + } + } + + public void shutdown() { + mConnection.disconnect(); + } + + @Override + public void onMediaScannerConnected() { + synchronized (mConnection) { + for (ScanRequest req : mPending.values()) { + req.exec(mConnection); + } + } + } + + @Override + public void onScanCompleted(String path, Uri uri) { + final ScanRequest req; + synchronized (mConnection) { + req = mPending.remove(path); + } + if (req == null) { + Log.w(TAG, "Missing request for path " + path); + return; + } + + // Update scanned column, which will kick off a database update pass, + // eventually deciding if overall service is ready for teardown. + final ContentValues values = new ContentValues(); + values.put(Downloads.Impl.COLUMN_MEDIA_SCANNED, 1); + if (uri != null) { + values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, uri.toString()); + } + + final ContentResolver resolver = mContext.getContentResolver(); + final Uri downloadUri = ContentUris.withAppendedId( + Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, req.id); + final int rows = resolver.update(downloadUri, values, null, null); + if (rows == 0) { + // Local row disappeared during scan; download was probably deleted + // so clean up now-orphaned media entry. + resolver.delete(uri, null, null); + } + } +} diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java index b97346b2..7d746cca 100644 --- a/src/com/android/providers/downloads/DownloadService.java +++ b/src/com/android/providers/downloads/DownloadService.java @@ -16,25 +16,25 @@ package com.android.providers.downloads; +import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static com.android.providers.downloads.Constants.TAG; import android.app.AlarmManager; +import android.app.DownloadManager; import android.app.PendingIntent; import android.app.Service; -import android.content.ComponentName; -import android.content.ContentValues; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; -import android.content.ServiceConnection; +import android.content.res.Resources; import android.database.ContentObserver; import android.database.Cursor; -import android.media.IMediaScannerListener; -import android.media.IMediaScannerService; import android.net.Uri; import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; +import android.os.Message; import android.os.Process; -import android.os.RemoteException; import android.provider.Downloads; import android.text.TextUtils; import android.util.Log; @@ -44,22 +44,41 @@ import com.android.internal.util.IndentingPrintWriter; import com.google.android.collect.Maps; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; +import com.google.common.collect.Sets; import java.io.File; import java.io.FileDescriptor; import java.io.PrintWriter; +import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; /** - * Performs the background downloads requested by applications that use the Downloads provider. + * Performs background downloads as requested by applications that use + * {@link DownloadManager}. Multiple start commands can be issued at this + * service, and it will continue running until no downloads are being actively + * processed. It may schedule alarms to resume downloads in future. + * <p> + * Any database updates important enough to initiate tasks should always be + * delivered through {@link Context#startService(Intent)}. */ public class DownloadService extends Service { - /** amount of time to wait to connect to MediaScannerService before timing out */ - private static final long WAIT_TIMEOUT = 10 * 1000; + // TODO: migrate WakeLock from individual DownloadThreads out into + // DownloadReceiver to protect our entire workflow. + + private static final boolean DEBUG_LIFECYCLE = false; + + @VisibleForTesting + SystemFacade mSystemFacade; + + private AlarmManager mAlarmManager; + private StorageManager mStorageManager; /** Observer to get notified when the content observer's data changes */ private DownloadManagerContentObserver mObserver; @@ -74,118 +93,41 @@ public class DownloadService extends Service { * content provider changes or disappears. */ @GuardedBy("mDownloads") - private Map<Long, DownloadInfo> mDownloads = Maps.newHashMap(); + private final Map<Long, DownloadInfo> mDownloads = Maps.newHashMap(); - /** - * The thread that updates the internal download list from the content - * provider. - */ - @VisibleForTesting - UpdateThread mUpdateThread; + private final ExecutorService mExecutor = buildDownloadExecutor(); - /** - * Whether the internal download list should be updated from the content - * provider. - */ - private boolean mPendingUpdate; + private static ExecutorService buildDownloadExecutor() { + final int maxConcurrent = Resources.getSystem().getInteger( + com.android.internal.R.integer.config_MaxConcurrentDownloadsAllowed); - /** - * The ServiceConnection object that tells us when we're connected to and disconnected from - * the Media Scanner - */ - private MediaScannerConnection mMediaScannerConnection; + // Create a bounded thread pool for executing downloads; it creates + // threads as needed (up to maximum) and reclaims them when finished. + final ThreadPoolExecutor executor = new ThreadPoolExecutor( + maxConcurrent, maxConcurrent, 10, TimeUnit.SECONDS, + new LinkedBlockingQueue<Runnable>()); + executor.allowCoreThreadTimeOut(true); + return executor; + } - private boolean mMediaScannerConnecting; + private DownloadScanner mScanner; - /** - * The IPC interface to the Media Scanner - */ - private IMediaScannerService mMediaScannerService; + private HandlerThread mUpdateThread; + private Handler mUpdateHandler; - @VisibleForTesting - SystemFacade mSystemFacade; - - private StorageManager mStorageManager; + private volatile int mLastStartId; /** * Receives notifications when the data in the content provider changes */ private class DownloadManagerContentObserver extends ContentObserver { - public DownloadManagerContentObserver() { super(new Handler()); } - /** - * Receives notification when the data in the observed content - * provider changes. - */ @Override public void onChange(final boolean selfChange) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Service ContentObserver received notification"); - } - updateFromProvider(); - } - - } - - /** - * Gets called back when the connection to the media - * scanner is established or lost. - */ - public class MediaScannerConnection implements ServiceConnection { - public void onServiceConnected(ComponentName className, IBinder service) { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Connected to Media Scanner"); - } - synchronized (DownloadService.this) { - try { - mMediaScannerConnecting = false; - mMediaScannerService = IMediaScannerService.Stub.asInterface(service); - if (mMediaScannerService != null) { - updateFromProvider(); - } - } finally { - // notify anyone waiting on successful connection to MediaService - DownloadService.this.notifyAll(); - } - } - } - - public void disconnectMediaScanner() { - synchronized (DownloadService.this) { - mMediaScannerConnecting = false; - if (mMediaScannerService != null) { - mMediaScannerService = null; - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Disconnecting from Media Scanner"); - } - try { - unbindService(this); - } catch (IllegalArgumentException ex) { - Log.w(Constants.TAG, "unbindService failed: " + ex); - } finally { - // notify anyone waiting on unsuccessful connection to MediaService - DownloadService.this.notifyAll(); - } - } - } - } - - public void onServiceDisconnected(ComponentName className) { - try { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "Disconnected from Media Scanner"); - } - } finally { - synchronized (DownloadService.this) { - mMediaScannerService = null; - mMediaScannerConnecting = false; - // notify anyone waiting on disconnect from MediaService - DownloadService.this.notifyAll(); - } - } + enqueueUpdate(); } } @@ -214,19 +156,21 @@ public class DownloadService extends Service { mSystemFacade = new RealSystemFacade(this); } - mObserver = new DownloadManagerContentObserver(); - getContentResolver().registerContentObserver(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - true, mObserver); + mAlarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + mStorageManager = new StorageManager(this); - mMediaScannerService = null; - mMediaScannerConnecting = false; - mMediaScannerConnection = new MediaScannerConnection(); + mUpdateThread = new HandlerThread(TAG + "-UpdateThread"); + mUpdateThread.start(); + mUpdateHandler = new Handler(mUpdateThread.getLooper(), mUpdateCallback); + + mScanner = new DownloadScanner(this); mNotifier = new DownloadNotifier(this); mNotifier.cancelAll(); - mStorageManager = StorageManager.getInstance(getApplicationContext()); - updateFromProvider(); + mObserver = new DownloadManagerContentObserver(); + getContentResolver().registerContentObserver(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + true, mObserver); } @Override @@ -235,16 +179,16 @@ public class DownloadService extends Service { if (Constants.LOGVV) { Log.v(Constants.TAG, "Service onStart"); } - updateFromProvider(); + mLastStartId = startId; + enqueueUpdate(); return returnValue; } - /** - * Cleans up when the service is destroyed - */ @Override public void onDestroy() { getContentResolver().unregisterContentObserver(mObserver); + mScanner.shutdown(); + mUpdateThread.quit(); if (Constants.LOGVV) { Log.v(Constants.TAG, "Service onDestroy"); } @@ -252,182 +196,179 @@ public class DownloadService extends Service { } /** - * Parses data from the content provider into private array + * Enqueue an {@link #updateLocked()} pass to occur in future. */ - private void updateFromProvider() { - synchronized (this) { - mPendingUpdate = true; - if (mUpdateThread == null) { - mUpdateThread = new UpdateThread(); - mSystemFacade.startThread(mUpdateThread); - } - } + private void enqueueUpdate() { + mUpdateHandler.removeMessages(MSG_UPDATE); + mUpdateHandler.obtainMessage(MSG_UPDATE, mLastStartId, -1).sendToTarget(); } - private class UpdateThread extends Thread { - public UpdateThread() { - super("Download Service"); - } + /** + * Enqueue an {@link #updateLocked()} pass to occur after delay, usually to + * catch any finished operations that didn't trigger an update pass. + */ + private void enqueueFinalUpdate() { + mUpdateHandler.removeMessages(MSG_FINAL_UPDATE); + mUpdateHandler.sendMessageDelayed( + mUpdateHandler.obtainMessage(MSG_FINAL_UPDATE, mLastStartId, -1), + 5 * MINUTE_IN_MILLIS); + } + private static final int MSG_UPDATE = 1; + private static final int MSG_FINAL_UPDATE = 2; + + private Handler.Callback mUpdateCallback = new Handler.Callback() { @Override - public void run() { + public boolean handleMessage(Message msg) { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); - boolean keepService = false; - // for each update from the database, remember which download is - // supposed to get restarted soonest in the future - long wakeUp = Long.MAX_VALUE; - for (;;) { - synchronized (DownloadService.this) { - if (mUpdateThread != this) { - throw new IllegalStateException( - "multiple UpdateThreads in DownloadService"); - } - if (!mPendingUpdate) { - mUpdateThread = null; - if (!keepService) { - stopSelf(); - } - if (wakeUp != Long.MAX_VALUE) { - scheduleAlarm(wakeUp); - } - return; + + final int startId = msg.arg1; + if (DEBUG_LIFECYCLE) Log.v(TAG, "Updating for startId " + startId); + + // Since database is current source of truth, our "active" status + // depends on database state. We always get one final update pass + // once the real actions have finished and persisted their state. + + // TODO: switch to asking real tasks to derive active state + // TODO: handle media scanner timeouts + + final boolean isActive; + synchronized (mDownloads) { + isActive = updateLocked(); + } + + if (msg.what == MSG_FINAL_UPDATE) { + // Dump thread stacks belonging to pool + for (Map.Entry<Thread, StackTraceElement[]> entry : + Thread.getAllStackTraces().entrySet()) { + if (entry.getKey().getName().startsWith("pool")) { + Log.d(TAG, entry.getKey() + ": " + Arrays.toString(entry.getValue())); } - mPendingUpdate = false; } - synchronized (mDownloads) { - long now = mSystemFacade.currentTimeMillis(); - boolean mustScan = false; - keepService = false; - wakeUp = Long.MAX_VALUE; - Set<Long> idsNoLongerInDatabase = new HashSet<Long>(mDownloads.keySet()); - - Cursor cursor = getContentResolver().query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - null, null, null, null); - if (cursor == null) { - continue; - } - try { - DownloadInfo.Reader reader = - new DownloadInfo.Reader(getContentResolver(), cursor); - int idColumn = cursor.getColumnIndexOrThrow(Downloads.Impl._ID); - if (Constants.LOGVV) { - Log.i(Constants.TAG, "number of rows from downloads-db: " + - cursor.getCount()); - } - for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { - long id = cursor.getLong(idColumn); - idsNoLongerInDatabase.remove(id); - DownloadInfo info = mDownloads.get(id); - if (info != null) { - updateDownload(reader, info, now); - } else { - info = insertDownloadLocked(reader, now); - } - - if (info.shouldScanFile() && !scanFile(info, true, false)) { - mustScan = true; - keepService = true; - } - if (info.hasCompletionNotification()) { - keepService = true; - } - long next = info.nextAction(now); - if (next == 0) { - keepService = true; - } else if (next > 0 && next < wakeUp) { - wakeUp = next; - } - } - } finally { - cursor.close(); - } + // Dump speed and update details + mNotifier.dumpSpeeds(); - for (Long id : idsNoLongerInDatabase) { - deleteDownloadLocked(id); - } + Log.wtf(TAG, "Final update pass triggered, isActive=" + isActive + + "; someone didn't update correctly."); + } - // is there a need to start the DownloadService? yes, if there are rows to be - // deleted. - if (!mustScan) { - for (DownloadInfo info : mDownloads.values()) { - if (info.mDeleted && TextUtils.isEmpty(info.mMediaProviderUri)) { - mustScan = true; - keepService = true; - break; - } - } - } - mNotifier.updateWith(mDownloads.values()); - if (mustScan) { - bindMediaScanner(); - } else { - mMediaScannerConnection.disconnectMediaScanner(); + if (isActive) { + // Still doing useful work, keep service alive. These active + // tasks will trigger another update pass when they're finished. + + // Enqueue delayed update pass to catch finished operations that + // didn't trigger an update pass; these are bugs. + enqueueFinalUpdate(); + + } else { + // No active tasks, and any pending update messages can be + // ignored, since any updates important enough to initiate tasks + // will always be delivered with a new startId. + + if (stopSelfResult(startId)) { + if (DEBUG_LIFECYCLE) Log.v(TAG, "Nothing left; stopped"); + getContentResolver().unregisterContentObserver(mObserver); + mScanner.shutdown(); + mUpdateThread.quit(); + } + } + + return true; + } + }; + + /** + * Update {@link #mDownloads} to match {@link DownloadProvider} state. + * Depending on current download state it may enqueue {@link DownloadThread} + * instances, request {@link DownloadScanner} scans, update user-visible + * notifications, and/or schedule future actions with {@link AlarmManager}. + * <p> + * Should only be called from {@link #mUpdateThread} as after being + * requested through {@link #enqueueUpdate()}. + * + * @return If there are active tasks being processed, as of the database + * snapshot taken in this update. + */ + private boolean updateLocked() { + final long now = mSystemFacade.currentTimeMillis(); + + boolean isActive = false; + long nextActionMillis = Long.MAX_VALUE; + + final Set<Long> staleIds = Sets.newHashSet(mDownloads.keySet()); + + final ContentResolver resolver = getContentResolver(); + final Cursor cursor = resolver.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, + null, null, null, null); + try { + final DownloadInfo.Reader reader = new DownloadInfo.Reader(resolver, cursor); + final int idColumn = cursor.getColumnIndexOrThrow(Downloads.Impl._ID); + while (cursor.moveToNext()) { + final long id = cursor.getLong(idColumn); + staleIds.remove(id); + + DownloadInfo info = mDownloads.get(id); + if (info != null) { + updateDownload(reader, info, now); + } else { + info = insertDownloadLocked(reader, now); + } + + if (info.mDeleted) { + // Delete download if requested, but only after cleaning up + if (!TextUtils.isEmpty(info.mMediaProviderUri)) { + resolver.delete(Uri.parse(info.mMediaProviderUri), null, null); } - // look for all rows with deleted flag set and delete the rows from the database - // permanently - for (DownloadInfo info : mDownloads.values()) { - if (info.mDeleted) { - // this row is to be deleted from the database. but does it have - // mediaProviderUri? - if (TextUtils.isEmpty(info.mMediaProviderUri)) { - if (info.shouldScanFile()) { - // initiate rescan of the file to - which will populate - // mediaProviderUri column in this row - if (!scanFile(info, false, true)) { - throw new IllegalStateException("scanFile failed!"); - } - continue; - } - } else { - // yes it has mediaProviderUri column already filled in. - // delete it from MediaProvider database. - getContentResolver().delete(Uri.parse(info.mMediaProviderUri), null, - null); - } - // delete the file - deleteFileIfExists(info.mFileName); - // delete from the downloads db - getContentResolver().delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - Downloads.Impl._ID + " = ? ", - new String[]{String.valueOf(info.mId)}); - } + deleteFileIfExists(info.mFileName); + resolver.delete(info.getAllDownloadsUri(), null, null); + + } else { + // Kick off download task if ready + final boolean activeDownload = info.startDownloadIfReady(mExecutor); + + // Kick off media scan if completed + final boolean activeScan = info.startScanIfReady(mScanner); + + if (DEBUG_LIFECYCLE && (activeDownload || activeScan)) { + Log.v(TAG, "Download " + info.mId + ": activeDownload=" + activeDownload + + ", activeScan=" + activeScan); } + + isActive |= activeDownload; + isActive |= activeScan; } + + // Keep track of nearest next action + nextActionMillis = Math.min(info.nextActionMillis(now), nextActionMillis); } + } finally { + cursor.close(); } - private void bindMediaScanner() { - if (!mMediaScannerConnecting) { - Intent intent = new Intent(); - intent.setClassName("com.android.providers.media", - "com.android.providers.media.MediaScannerService"); - mMediaScannerConnecting = true; - bindService(intent, mMediaScannerConnection, BIND_AUTO_CREATE); - } + // Clean up stale downloads that disappeared + for (Long id : staleIds) { + deleteDownloadLocked(id); } - private void scheduleAlarm(long wakeUp) { - AlarmManager alarms = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - if (alarms == null) { - Log.e(Constants.TAG, "couldn't get alarm manager"); - return; - } + // Update notifications visible to user + mNotifier.updateWith(mDownloads.values()); + // Set alarm when next action is in future. It's okay if the service + // continues to run in meantime, since it will kick off an update pass. + if (nextActionMillis > 0 && nextActionMillis < Long.MAX_VALUE) { if (Constants.LOGV) { - Log.v(Constants.TAG, "scheduling retry in " + wakeUp + "ms"); + Log.v(TAG, "scheduling start in " + nextActionMillis + "ms"); } - Intent intent = new Intent(Constants.ACTION_RETRY); - intent.setClassName("com.android.providers.downloads", - DownloadReceiver.class.getName()); - alarms.set( - AlarmManager.RTC_WAKEUP, - mSystemFacade.currentTimeMillis() + wakeUp, - PendingIntent.getBroadcast(DownloadService.this, 0, intent, - PendingIntent.FLAG_ONE_SHOT)); + final Intent intent = new Intent(Constants.ACTION_RETRY); + intent.setClass(this, DownloadReceiver.class); + mAlarmManager.set(AlarmManager.RTC_WAKEUP, now + nextActionMillis, + PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_ONE_SHOT)); } + + return isActive; } /** @@ -435,14 +376,14 @@ public class DownloadService extends Service { * download if appropriate. */ private DownloadInfo insertDownloadLocked(DownloadInfo.Reader reader, long now) { - DownloadInfo info = reader.newDownloadInfo(this, mSystemFacade); + final DownloadInfo info = reader.newDownloadInfo( + this, mSystemFacade, mStorageManager, mNotifier); mDownloads.put(info.mId, info); if (Constants.LOGVV) { Log.v(Constants.TAG, "processing inserted download " + info.mId); } - info.startIfReady(now, mStorageManager); return info; } @@ -450,15 +391,11 @@ public class DownloadService extends Service { * Updates the local copy of the info about a download. */ private void updateDownload(DownloadInfo.Reader reader, DownloadInfo info, long now) { - int oldVisibility = info.mVisibility; - int oldStatus = info.mStatus; - reader.updateFromDatabase(info); if (Constants.LOGVV) { Log.v(Constants.TAG, "processing updated download " + info.mId + ", status: " + info.mStatus); } - info.startIfReady(now, mStorageManager); } /** @@ -473,88 +410,20 @@ public class DownloadService extends Service { if (Constants.LOGVV) { Log.d(TAG, "deleteDownloadLocked() deleting " + info.mFileName); } - new File(info.mFileName).delete(); + deleteFileIfExists(info.mFileName); } mDownloads.remove(info.mId); } - /** - * Attempts to scan the file if necessary. - * @return true if the file has been properly scanned. - */ - private boolean scanFile(DownloadInfo info, final boolean updateDatabase, - final boolean deleteFile) { - synchronized (this) { - if (mMediaScannerService == null) { - // not bound to mediaservice. but if in the process of connecting to it, wait until - // connection is resolved - while (mMediaScannerConnecting) { - Log.d(Constants.TAG, "waiting for mMediaScannerService service: "); - try { - this.wait(WAIT_TIMEOUT); - } catch (InterruptedException e1) { - throw new IllegalStateException("wait interrupted"); - } - } - } - // do we have mediaservice? - if (mMediaScannerService == null) { - // no available MediaService And not even in the process of connecting to it - return false; - } - if (Constants.LOGV) { - Log.v(Constants.TAG, "Scanning file " + info.mFileName); - } - try { - final Uri key = info.getAllDownloadsUri(); - final long id = info.mId; - mMediaScannerService.requestScanFile(info.mFileName, info.mMimeType, - new IMediaScannerListener.Stub() { - public void scanCompleted(String path, Uri uri) { - if (updateDatabase) { - // Mark this as 'scanned' in the database - // so that it is NOT subject to re-scanning by MediaScanner - // next time this database row row is encountered - ContentValues values = new ContentValues(); - values.put(Constants.MEDIA_SCANNED, 1); - if (uri != null) { - values.put(Downloads.Impl.COLUMN_MEDIAPROVIDER_URI, - uri.toString()); - } - getContentResolver().update(key, values, null, null); - } else if (deleteFile) { - if (uri != null) { - // use the Uri returned to delete it from the MediaProvider - getContentResolver().delete(uri, null, null); - } - // delete the file and delete its row from the downloads db - deleteFileIfExists(path); - getContentResolver().delete( - Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, - Downloads.Impl._ID + " = ? ", - new String[]{String.valueOf(id)}); - } - } - }); - return true; - } catch (RemoteException e) { - Log.w(Constants.TAG, "Failed to scan file " + info.mFileName); - return false; - } - } - } - private void deleteFileIfExists(String path) { - try { - if (!TextUtils.isEmpty(path)) { - if (Constants.LOGVV) { - Log.d(TAG, "deleteFileIfExists() deleting " + path); - } - File file = new File(path); - file.delete(); + if (!TextUtils.isEmpty(path)) { + if (Constants.LOGVV) { + Log.d(TAG, "deleteFileIfExists() deleting " + path); + } + final File file = new File(path); + if (file.exists() && !file.delete()) { + Log.w(TAG, "file: '" + path + "' couldn't be deleted"); } - } catch (Exception e) { - Log.w(Constants.TAG, "file: '" + path + "' couldn't be deleted", e); } } diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java index 34bc8e34..6a0eb47e 100644 --- a/src/com/android/providers/downloads/DownloadThread.java +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -16,16 +16,33 @@ package com.android.providers.downloads; +import static android.provider.Downloads.Impl.STATUS_BAD_REQUEST; +import static android.provider.Downloads.Impl.STATUS_CANNOT_RESUME; +import static android.provider.Downloads.Impl.STATUS_FILE_ERROR; +import static android.provider.Downloads.Impl.STATUS_HTTP_DATA_ERROR; +import static android.provider.Downloads.Impl.STATUS_TOO_MANY_REDIRECTS; +import static android.provider.Downloads.Impl.STATUS_WAITING_FOR_NETWORK; +import static android.provider.Downloads.Impl.STATUS_WAITING_TO_RETRY; +import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static com.android.providers.downloads.Constants.TAG; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; +import static java.net.HttpURLConnection.HTTP_MOVED_PERM; +import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_PARTIAL; +import static java.net.HttpURLConnection.HTTP_SEE_OTHER; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.drm.DrmManagerClient; +import android.drm.DrmOutputStream; +import android.net.ConnectivityManager; import android.net.INetworkPolicyListener; +import android.net.NetworkInfo; import android.net.NetworkPolicyManager; -import android.net.Proxy; import android.net.TrafficStats; -import android.net.http.AndroidHttpClient; import android.os.FileUtils; import android.os.PowerManager; import android.os.Process; @@ -35,39 +52,51 @@ import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import org.apache.http.Header; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.conn.params.ConnRouteParams; +import com.android.providers.downloads.DownloadInfo.NetworkState; import java.io.File; -import java.io.FileNotFoundException; +import java.io.FileDescriptor; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.SyncFailedException; -import java.net.URI; -import java.net.URISyntaxException; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; + +import libcore.io.IoUtils; /** - * Runs an actual download + * Task which executes a given {@link DownloadInfo}: making network requests, + * persisting data to disk, and updating {@link DownloadProvider}. */ -public class DownloadThread extends Thread { +public class DownloadThread implements Runnable { + + // TODO: bind each download to a specific network interface to avoid state + // checking races once we have ConnectivityManager API + + private static final int HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416; + private static final int HTTP_TEMP_REDIRECT = 307; + + private static final int DEFAULT_TIMEOUT = (int) (20 * SECOND_IN_MILLIS); private final Context mContext; private final DownloadInfo mInfo; private final SystemFacade mSystemFacade; private final StorageManager mStorageManager; - private DrmConvertSession mDrmConvertSession; + private final DownloadNotifier mNotifier; private volatile boolean mPolicyDirty; public DownloadThread(Context context, SystemFacade systemFacade, DownloadInfo info, - StorageManager storageManager) { + StorageManager storageManager, DownloadNotifier notifier) { mContext = context; mSystemFacade = systemFacade; mInfo = info; mStorageManager = storageManager; + mNotifier = notifier; } /** @@ -86,12 +115,8 @@ public class DownloadThread extends Thread { */ 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 boolean mGotData = false; public String mRequestUri; public long mTotalBytes = -1; @@ -100,6 +125,7 @@ public class DownloadThread extends Thread { public boolean mContinuingDownload = false; public long mBytesNotified = 0; public long mTimeLastNotification = 0; + public int mNetworkType = ConnectivityManager.TYPE_NONE; /** Historical bytes/second speed of this download. */ public long mSpeed; @@ -108,6 +134,13 @@ public class DownloadThread extends Thread { /** Bytes transferred since current sample started. */ public long mSpeedSampleBytes; + public long mContentLength = -1; + public String mContentDisposition; + public String mContentLocation; + + public int mRedirectionCount; + public URL mUrl; + public State(DownloadInfo info) { mMimeType = Intent.normalizeMimeType(info.mMimeType); mRequestUri = info.mUri; @@ -115,33 +148,23 @@ public class DownloadThread extends Thread { mTotalBytes = info.mTotalBytes; mCurrentBytes = info.mCurrentBytes; } - } - /** - * State within executeDownload() - */ - private static class InnerState { - public String mHeaderContentLength; - public String mHeaderContentDisposition; - public String mHeaderContentLocation; + public void resetBeforeExecute() { + // Reset any state from previous execution + mContentLength = -1; + mContentDisposition = null; + mContentLocation = null; + mRedirectionCount = 0; + } } - /** - * 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 - */ @Override public void run() { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); try { runInternal(); } finally { - DownloadHandler.getInstance().dequeueDownload(mInfo.mId); + mNotifier.notifyDownloadSpeed(mInfo.mId, 0); } } @@ -155,9 +178,9 @@ public class DownloadThread extends Thread { } State state = new State(mInfo); - AndroidHttpClient client = null; PowerManager.WakeLock wakeLock = null; int finalStatus = Downloads.Impl.STATUS_UNKNOWN_ERROR; + int numFailed = mInfo.mNumFailed; String errorMsg = null; final NetworkPolicyManager netPolicy = NetworkPolicyManager.from(mContext); @@ -170,39 +193,29 @@ public class DownloadThread extends Thread { // while performing download, register for rules updates netPolicy.registerListener(mPolicyListener); - if (Constants.LOGV) { - Log.v(Constants.TAG, "initiating download for " + mInfo.mUri); - } + Log.i(Constants.TAG, "Download " + mInfo.mId + " starting"); - client = AndroidHttpClient.newInstance(userAgent(), mContext); + // Remember which network this download started on; used to + // determine if errors were due to network changes. + final NetworkInfo info = mSystemFacade.getActiveNetworkInfo(mInfo.mUid); + if (info != null) { + state.mNetworkType = info.getType(); + } - // network traffic on this thread should be counted against the - // requesting uid, and is tagged with well-known value. + // Network traffic on this thread should be counted against the + // requesting UID, and is tagged with well-known value. TrafficStats.setThreadStatsTag(TrafficStats.TAG_SYSTEM_DOWNLOAD); TrafficStats.setThreadStatsUid(mInfo.mUid); - boolean finished = false; - while(!finished) { - Log.i(Constants.TAG, "Initiating request for download " + mInfo.mId); - // 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, state.mRequestUri)); - HttpGet request = new HttpGet(state.mRequestUri); - try { - executeDownload(state, client, request); - finished = true; - } catch (RetryDownload exc) { - // fall through - } finally { - request.abort(); - request = null; - } + try { + // TODO: migrate URL sanity checking into client side of API + state.mUrl = new URL(state.mRequestUri); + } catch (MalformedURLException e) { + throw new StopRequestException(STATUS_BAD_REQUEST, e); } - if (Constants.LOGV) { - Log.v(Constants.TAG, "download completed for " + mInfo.mUri); - } + executeDownload(state); + finalizeDestinationFile(state); finalStatus = Downloads.Impl.STATUS_SUCCESS; } catch (StopRequestException error) { @@ -213,9 +226,37 @@ public class DownloadThread extends Thread { if (Constants.LOGV) { Log.w(Constants.TAG, msg, error); } - finalStatus = error.mFinalStatus; + finalStatus = error.getFinalStatus(); + + // Nobody below our level should request retries, since we handle + // failure counts at this level. + if (finalStatus == STATUS_WAITING_TO_RETRY) { + throw new IllegalStateException("Execution should always throw final error codes"); + } + + // Some errors should be retryable, unless we fail too many times. + if (isStatusRetryable(finalStatus)) { + if (state.mGotData) { + numFailed = 1; + } else { + numFailed += 1; + } + + if (numFailed < Constants.MAX_RETRIES) { + final NetworkInfo info = mSystemFacade.getActiveNetworkInfo(mInfo.mUid); + if (info != null && info.getType() == state.mNetworkType + && info.isConnected()) { + // Underlying network is still intact, use normal backoff + finalStatus = STATUS_WAITING_TO_RETRY; + } else { + // Network changed, retry on any next available + finalStatus = STATUS_WAITING_FOR_NETWORK; + } + } + } + // fall through to finally block - } catch (Throwable ex) { //sometimes the socket code throws unchecked exceptions + } catch (Throwable ex) { errorMsg = ex.getMessage(); String msg = "Exception for id " + mInfo.mId + ": " + errorMsg; Log.w(Constants.TAG, msg, ex); @@ -225,14 +266,11 @@ public class DownloadThread extends Thread { TrafficStats.clearThreadStatsTag(); TrafficStats.clearThreadStatsUid(); - if (client != null) { - client.close(); - client = null; - } cleanupDestination(state, finalStatus); - notifyDownloadCompleted(finalStatus, state.mCountRetry, state.mRetryAfter, - state.mGotData, state.mFilename, - state.mNewUri, state.mMimeType, errorMsg); + notifyDownloadCompleted(state, finalStatus, errorMsg, numFailed); + + Log.i(Constants.TAG, "Download " + mInfo.mId + " finished with status " + + Downloads.Impl.statusToString(finalStatus)); netPolicy.unregisterListener(mPolicyListener); @@ -245,16 +283,12 @@ public class DownloadThread extends Thread { } /** - * Fully execute a single download request - setup and send the request, handle the response, - * and transfer the data to the destination file. + * 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 StopRequestException, RetryDownload { - InnerState innerState = new InnerState(); - byte data[] = new byte[Constants.BUFFER_SIZE]; - - setupDestinationFile(state, innerState); - addRequestHeaders(state, request); + private void executeDownload(State state) throws StopRequestException { + state.resetBeforeExecute(); + setupDestinationFile(state); // skip when already finished; remove after fixing race in 5217390 if (state.mCurrentBytes == state.mTotalBytes) { @@ -263,19 +297,136 @@ public class DownloadThread extends Thread { return; } - // check just before sending the request to avoid using an invalid connection at all - checkConnectivity(); - - HttpResponse response = sendRequest(state, client, request); - handleExceptionalStatus(state, innerState, response); + while (state.mRedirectionCount++ < Constants.MAX_REDIRECTS) { + // Open connection and follow any redirects until we have a useful + // response with body. + HttpURLConnection conn = null; + try { + checkConnectivity(); + conn = (HttpURLConnection) state.mUrl.openConnection(); + conn.setInstanceFollowRedirects(false); + conn.setConnectTimeout(DEFAULT_TIMEOUT); + conn.setReadTimeout(DEFAULT_TIMEOUT); + + addRequestHeaders(state, conn); + + final int responseCode = conn.getResponseCode(); + switch (responseCode) { + case HTTP_OK: + if (state.mContinuingDownload) { + throw new StopRequestException( + STATUS_CANNOT_RESUME, "Expected partial, but received OK"); + } + processResponseHeaders(state, conn); + transferData(state, conn); + return; + + case HTTP_PARTIAL: + if (!state.mContinuingDownload) { + throw new StopRequestException( + STATUS_CANNOT_RESUME, "Expected OK, but received partial"); + } + transferData(state, conn); + return; + + case HTTP_MOVED_PERM: + case HTTP_MOVED_TEMP: + case HTTP_SEE_OTHER: + case HTTP_TEMP_REDIRECT: + final String location = conn.getHeaderField("Location"); + state.mUrl = new URL(state.mUrl, location); + if (responseCode == HTTP_MOVED_PERM) { + // Push updated URL back to database + state.mRequestUri = state.mUrl.toString(); + } + continue; + + case HTTP_REQUESTED_RANGE_NOT_SATISFIABLE: + throw new StopRequestException( + STATUS_CANNOT_RESUME, "Requested range not satisfiable"); + + case HTTP_UNAVAILABLE: + parseRetryAfterHeaders(state, conn); + throw new StopRequestException( + HTTP_UNAVAILABLE, conn.getResponseMessage()); + + case HTTP_INTERNAL_ERROR: + throw new StopRequestException( + HTTP_INTERNAL_ERROR, conn.getResponseMessage()); + + default: + StopRequestException.throwUnhandledHttpError( + responseCode, conn.getResponseMessage()); + } + } catch (IOException e) { + // Trouble with low-level sockets + throw new StopRequestException(STATUS_HTTP_DATA_ERROR, e); - if (Constants.LOGV) { - Log.v(Constants.TAG, "received response for " + mInfo.mUri); + } finally { + if (conn != null) conn.disconnect(); + } } - processResponseHeaders(state, innerState, response); - InputStream entityStream = openResponseEntity(state, response); - transferData(state, innerState, data, entityStream); + throw new StopRequestException(STATUS_TOO_MANY_REDIRECTS, "Too many redirects"); + } + + /** + * Transfer data from the given connection to the destination file. + */ + private void transferData(State state, HttpURLConnection conn) throws StopRequestException { + DrmManagerClient drmClient = null; + InputStream in = null; + OutputStream out = null; + FileDescriptor outFd = null; + try { + try { + in = conn.getInputStream(); + } catch (IOException e) { + throw new StopRequestException(STATUS_HTTP_DATA_ERROR, e); + } + + try { + if (DownloadDrmHelper.isDrmConvertNeeded(state.mMimeType)) { + drmClient = new DrmManagerClient(mContext); + final RandomAccessFile file = new RandomAccessFile( + new File(state.mFilename), "rw"); + out = new DrmOutputStream(drmClient, file, state.mMimeType); + outFd = file.getFD(); + } else { + out = new FileOutputStream(state.mFilename, true); + outFd = ((FileOutputStream) out).getFD(); + } + } catch (IOException e) { + throw new StopRequestException(STATUS_FILE_ERROR, e); + } + + // Start streaming data, periodically watch for pause/cancel + // commands and checking disk space as needed. + transferData(state, in, out); + + try { + if (out instanceof DrmOutputStream) { + ((DrmOutputStream) out).finish(); + } + } catch (IOException e) { + throw new StopRequestException(STATUS_FILE_ERROR, e); + } + + } finally { + if (drmClient != null) { + drmClient.release(); + } + + IoUtils.closeQuietly(in); + + try { + if (out != null) out.flush(); + if (outFd != null) outFd.sync(); + } catch (IOException e) { + } finally { + IoUtils.closeQuietly(out); + } + } } /** @@ -285,40 +436,38 @@ public class DownloadThread extends Thread { // checking connectivity will apply current policy mPolicyDirty = false; - int networkUsable = mInfo.checkCanUseNetwork(); - if (networkUsable != DownloadInfo.NETWORK_OK) { + final NetworkState networkUsable = mInfo.checkCanUseNetwork(); + if (networkUsable != NetworkState.OK) { int status = Downloads.Impl.STATUS_WAITING_FOR_NETWORK; - if (networkUsable == DownloadInfo.NETWORK_UNUSABLE_DUE_TO_SIZE) { + if (networkUsable == NetworkState.UNUSABLE_DUE_TO_SIZE) { status = Downloads.Impl.STATUS_QUEUED_FOR_WIFI; mInfo.notifyPauseDueToSize(true); - } else if (networkUsable == DownloadInfo.NETWORK_RECOMMENDED_UNUSABLE_DUE_TO_SIZE) { + } else if (networkUsable == NetworkState.RECOMMENDED_UNUSABLE_DUE_TO_SIZE) { status = Downloads.Impl.STATUS_QUEUED_FOR_WIFI; mInfo.notifyPauseDueToSize(false); } - throw new StopRequestException(status, - mInfo.getLogMessageForNetworkError(networkUsable)); + throw new StopRequestException(status, networkUsable.name()); } } /** - * 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 + * Transfer as much data as possible from the HTTP response to the + * destination file. */ - private void transferData( - State state, InnerState innerState, byte[] data, InputStream entityStream) + private void transferData(State state, InputStream in, OutputStream out) throws StopRequestException { + final byte data[] = new byte[Constants.BUFFER_SIZE]; for (;;) { - int bytesRead = readFromResponse(state, innerState, data, entityStream); + int bytesRead = readFromResponse(state, data, in); if (bytesRead == -1) { // success, end of stream already reached - handleEndOfStream(state, innerState); + handleEndOfStream(state); return; } state.mGotData = true; - writeDataToDestination(state, data, bytesRead); + writeDataToDestination(state, data, bytesRead, out); state.mCurrentBytes += bytesRead; - reportProgress(state, innerState); + reportProgress(state); if (Constants.LOGVV) { Log.v(Constants.TAG, "downloaded " + state.mCurrentBytes + " for " @@ -332,11 +481,10 @@ public class DownloadThread extends Thread { /** * Called after a successful completion to take any necessary action on the downloaded file. */ - private void finalizeDestinationFile(State state) throws StopRequestException { + private void finalizeDestinationFile(State state) { if (state.mFilename != null) { // make sure the file is readable FileUtils.setPermissions(state.mFilename, 0644, -1, -1); - syncDestination(state); } } @@ -345,11 +493,6 @@ public class DownloadThread extends Thread { * the downloaded file. */ private void cleanupDestination(State state, int finalStatus) { - if (mDrmConvertSession != null) { - finalStatus = mDrmConvertSession.close(state.mFilename); - } - - closeDestination(state); if (state.mFilename != null && Downloads.Impl.isStatusError(finalStatus)) { if (Constants.LOGVV) { Log.d(TAG, "cleanupDestination() deleting " + state.mFilename); @@ -360,53 +503,6 @@ public class DownloadThread extends Thread { } /** - * 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); - } - } - } - } - - /** - * 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. */ @@ -430,7 +526,7 @@ public class DownloadThread extends Thread { /** * Report download progress through the database if necessary. */ - private void reportProgress(State state, InnerState innerState) { + private void reportProgress(State state) { final long now = SystemClock.elapsedRealtime(); final long sampleDelta = now - state.mSpeedSampleStart; @@ -444,10 +540,13 @@ public class DownloadThread extends Thread { state.mSpeed = ((state.mSpeed * 3) + sampleSpeed) / 4; } + // Only notify once we have a full sample window + if (state.mSpeedSampleStart != 0) { + mNotifier.notifyDownloadSpeed(mInfo.mId, state.mSpeed); + } + state.mSpeedSampleStart = now; state.mSpeedSampleBytes = state.mCurrentBytes; - - DownloadHandler.getInstance().setCurrentSpeed(mInfo.mId, state.mSpeed); } if (state.mCurrentBytes - state.mBytesNotified > Constants.MIN_PROGRESS_STEP && @@ -465,37 +564,25 @@ public class DownloadThread extends Thread { * @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) + private void writeDataToDestination(State state, byte[] data, int bytesRead, OutputStream out) throws StopRequestException { - for (;;) { + mStorageManager.verifySpaceBeforeWritingToFile( + mInfo.mDestination, state.mFilename, bytesRead); + + boolean forceVerified = false; + while (true) { try { - if (state.mStream == null) { - state.mStream = new FileOutputStream(state.mFilename, true); - } - mStorageManager.verifySpaceBeforeWritingToFile(mInfo.mDestination, state.mFilename, - bytesRead); - if (!DownloadDrmHelper.isDrmConvertNeeded(mInfo.mMimeType)) { - state.mStream.write(data, 0, bytesRead); - } else { - byte[] convertedData = mDrmConvertSession.convert(data, bytesRead); - if (convertedData != null) { - state.mStream.write(convertedData, 0, convertedData.length); - } else { - throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, - "Error converting drm data."); - } - } + out.write(data, 0, bytesRead); return; } catch (IOException ex) { - // couldn't write to file. are we out of space? check. - // TODO this check should only be done once. why is this being done - // in a while(true) loop (see the enclosing statement: for(;;) - if (state.mStream != null) { + // TODO: better differentiate between DRM and disk failures + if (!forceVerified) { + // couldn't write to file. are we out of space? check. mStorageManager.verifySpace(mInfo.mDestination, state.mFilename, bytesRead); - } - } finally { - if (mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL) { - closeDestination(state); + forceVerified = true; + } else { + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, + "Failed to write data: " + ex); } } } @@ -505,29 +592,30 @@ public class DownloadThread extends Thread { * Called when we've reached the end of the HTTP response stream, to update the database and * check for consistency. */ - private void handleEndOfStream(State state, InnerState innerState) throws StopRequestException { + private void handleEndOfStream(State state) throws StopRequestException { ContentValues values = new ContentValues(); values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, state.mCurrentBytes); - if (innerState.mHeaderContentLength == null) { + if (state.mContentLength == -1) { values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, state.mCurrentBytes); } mContext.getContentResolver().update(mInfo.getAllDownloadsUri(), values, null, null); - boolean lengthMismatched = (innerState.mHeaderContentLength != null) - && (state.mCurrentBytes != Integer.parseInt(innerState.mHeaderContentLength)); + final boolean lengthMismatched = (state.mContentLength != -1) + && (state.mCurrentBytes != state.mContentLength); if (lengthMismatched) { if (cannotResume(state)) { - throw new StopRequestException(Downloads.Impl.STATUS_CANNOT_RESUME, - "mismatched content length"); + throw new StopRequestException(STATUS_CANNOT_RESUME, + "mismatched content length; unable to resume"); } else { - throw new StopRequestException(getFinalStatusForHttpError(state), + throw new StopRequestException(STATUS_HTTP_DATA_ERROR, "closed socket before end of file"); } } } private boolean cannotResume(State state) { - return state.mCurrentBytes > 0 && !mInfo.mNoIntegrity && state.mHeaderETag == null; + return (state.mCurrentBytes > 0 && !mInfo.mNoIntegrity && state.mHeaderETag == null) + || DownloadDrmHelper.isDrmConvertNeeded(state.mMimeType); } /** @@ -536,91 +624,51 @@ public class DownloadThread extends Thread { * @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 StopRequestException { + private int readFromResponse(State state, byte[] data, InputStream entityStream) + throws StopRequestException { try { return entityStream.read(data); } catch (IOException ex) { - logNetworkState(mInfo.mUid); + // TODO: handle stream errors the same as other retries + if ("unexpected end of stream".equals(ex.getMessage())) { + return -1; + } + ContentValues values = new ContentValues(); values.put(Downloads.Impl.COLUMN_CURRENT_BYTES, state.mCurrentBytes); mContext.getContentResolver().update(mInfo.getAllDownloadsUri(), values, null, null); if (cannotResume(state)) { - String message = "while reading response: " + ex.toString() - + ", can't resume interrupted download with no ETag"; - throw new StopRequestException(Downloads.Impl.STATUS_CANNOT_RESUME, - message, ex); + throw new StopRequestException(STATUS_CANNOT_RESUME, + "Failed reading response: " + ex + "; unable to resume", ex); } else { - throw new StopRequestException(getFinalStatusForHttpError(state), - "while reading response: " + ex.toString(), ex); + throw new StopRequestException(STATUS_HTTP_DATA_ERROR, + "Failed reading response: " + ex, ex); } } } /** - * Open a stream for the HTTP response entity, handling I/O errors. - * @return an InputStream to read the response entity + * Prepare target file based on given network response. Derives filename and + * target size as needed. */ - private InputStream openResponseEntity(State state, HttpResponse response) + private void processResponseHeaders(State state, HttpURLConnection conn) throws StopRequestException { - try { - return response.getEntity().getContent(); - } catch (IOException ex) { - logNetworkState(mInfo.mUid); - throw new StopRequestException(getFinalStatusForHttpError(state), - "while getting entity: " + ex.toString(), ex); - } - } + // TODO: fallocate the entire file if header gave us specific length - private void logNetworkState(int uid) { - if (Constants.LOGX) { - Log.i(Constants.TAG, - "Net " + (Helpers.isNetworkAvailable(mSystemFacade, uid) ? "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 StopRequestException { - if (state.mContinuingDownload) { - // ignore response headers on resume requests - return; - } - - readResponseHeaders(state, innerState, response); - if (DownloadDrmHelper.isDrmConvertNeeded(state.mMimeType)) { - mDrmConvertSession = DrmConvertSession.open(mContext, state.mMimeType); - if (mDrmConvertSession == null) { - throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE, "Mimetype " - + state.mMimeType + " can not be converted."); - } - } + readResponseHeaders(state, conn); state.mFilename = Helpers.generateSaveFile( mContext, mInfo.mUri, mInfo.mHint, - innerState.mHeaderContentDisposition, - innerState.mHeaderContentLocation, + state.mContentDisposition, + state.mContentLocation, state.mMimeType, mInfo.mDestination, - (innerState.mHeaderContentLength != null) ? - Long.parseLong(innerState.mHeaderContentLength) : 0, - mInfo.mIsPublicApi, mStorageManager); - try { - state.mStream = new FileOutputStream(state.mFilename); - } catch (FileNotFoundException exc) { - throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, - "while opening destination file: " + exc.toString(), exc); - } - if (Constants.LOGV) { - Log.v(Constants.TAG, "writing " + mInfo.mUri + " to " + state.mFilename); - } + state.mContentLength, + mStorageManager); - updateDatabaseFromHeaders(state, innerState); + updateDatabaseFromHeaders(state); // check connectivity again now that we know the total size checkConnectivity(); } @@ -629,7 +677,7 @@ public class DownloadThread extends Thread { * Update necessary database fields based on values of HTTP response headers that have been * read. */ - private void updateDatabaseFromHeaders(State state, InnerState innerState) { + private void updateDatabaseFromHeaders(State state) { ContentValues values = new ContentValues(); values.put(Downloads.Impl._DATA, state.mFilename); if (state.mHeaderETag != null) { @@ -645,219 +693,48 @@ public class DownloadThread extends Thread { /** * Read headers from the HTTP response and store them into local state. */ - private void readResponseHeaders(State state, InnerState innerState, HttpResponse response) + private void readResponseHeaders(State state, HttpURLConnection conn) throws StopRequestException { - 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 = Intent.normalizeMimeType(header.getValue()); - } - } - header = response.getFirstHeader("ETag"); - if (header != null) { - state.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(); - state.mTotalBytes = 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: " + state.mHeaderETag); - Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding); - } - - boolean noSizeInfo = innerState.mHeaderContentLength == null - && (headerTransferEncoding == null - || !headerTransferEncoding.equalsIgnoreCase("chunked")); - if (!mInfo.mNoIntegrity && noSizeInfo) { - throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, - "can't know size of download, giving up"); - } - } + state.mContentDisposition = conn.getHeaderField("Content-Disposition"); + state.mContentLocation = conn.getHeaderField("Content-Location"); - /** - * Check the HTTP response status and handle anything unusual (e.g. not 200/206). - */ - private void handleExceptionalStatus(State state, InnerState innerState, HttpResponse response) - throws StopRequestException, 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); + if (state.mMimeType == null) { + state.mMimeType = Intent.normalizeMimeType(conn.getContentType()); } - if (Constants.LOGV) { - Log.i(Constants.TAG, "recevd_status = " + statusCode + - ", mContinuingDownload = " + state.mContinuingDownload); - } - int expectedStatus = state.mContinuingDownload ? 206 : Downloads.Impl.STATUS_SUCCESS; - if (statusCode != expectedStatus) { - handleOtherStatus(state, innerState, statusCode); - } - } + state.mHeaderETag = conn.getHeaderField("ETag"); - /** - * Handle a status that we don't know how to deal with properly. - */ - private void handleOtherStatus(State state, InnerState innerState, int statusCode) - throws StopRequestException { - if (statusCode == 416) { - // range request failed. it should never fail. - throw new IllegalStateException("Http Range request failure: totalBytes = " + - state.mTotalBytes + ", bytes recvd so far: " + state.mCurrentBytes); - } - int finalStatus; - if (Downloads.Impl.isStatusError(statusCode)) { - finalStatus = statusCode; - } else if (statusCode >= 300 && statusCode < 400) { - finalStatus = Downloads.Impl.STATUS_UNHANDLED_REDIRECT; - } else if (state.mContinuingDownload && statusCode == Downloads.Impl.STATUS_SUCCESS) { - finalStatus = Downloads.Impl.STATUS_CANNOT_RESUME; + final String transferEncoding = conn.getHeaderField("Transfer-Encoding"); + if (transferEncoding == null) { + state.mContentLength = getHeaderFieldLong(conn, "Content-Length", -1); } else { - finalStatus = Downloads.Impl.STATUS_UNHANDLED_HTTP_CODE; + Log.i(TAG, "Ignoring Content-Length since Transfer-Encoding is also defined"); + state.mContentLength = -1; } - throw new StopRequestException(finalStatus, "http error " + - statusCode + ", mContinuingDownload: " + state.mContinuingDownload); - } - /** - * Handle a 3xx redirect status. - */ - private void handleRedirect(State state, HttpResponse response, int statusCode) - throws StopRequestException, RetryDownload { - if (Constants.LOGVV) { - Log.v(Constants.TAG, "got HTTP redirect " + statusCode); - } - if (state.mRedirectCount >= Constants.MAX_REDIRECTS) { - throw new StopRequestException(Downloads.Impl.STATUS_TOO_MANY_REDIRECTS, - "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); - } - throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, - "Couldn't resolve redirect URI"); - } - ++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(); - } + state.mTotalBytes = state.mContentLength; + mInfo.mTotalBytes = state.mContentLength; - /** - * Handle a 503 Service Unavailable status by processing the Retry-After header. - */ - private void handleServiceUnavailable(State state, HttpResponse response) - throws StopRequestException { - 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 StopRequestException(Downloads.Impl.STATUS_WAITING_TO_RETRY, - "got 503 Service Unavailable, will retry later"); - } - - /** - * Send the request to the server, handling any I/O exceptions. - */ - private HttpResponse sendRequest(State state, AndroidHttpClient client, HttpGet request) - throws StopRequestException { - try { - return client.execute(request); - } catch (IllegalArgumentException ex) { - throw new StopRequestException(Downloads.Impl.STATUS_HTTP_DATA_ERROR, - "while trying to execute request: " + ex.toString(), ex); - } catch (IOException ex) { - logNetworkState(mInfo.mUid); - throw new StopRequestException(getFinalStatusForHttpError(state), - "while trying to execute request: " + ex.toString(), ex); + final boolean noSizeInfo = state.mContentLength == -1 + && (transferEncoding == null || !transferEncoding.equalsIgnoreCase("chunked")); + if (!mInfo.mNoIntegrity && noSizeInfo) { + throw new StopRequestException(STATUS_CANNOT_RESUME, + "can't know size of download, giving up"); } } - private int getFinalStatusForHttpError(State state) { - int networkUsable = mInfo.checkCanUseNetwork(); - if (networkUsable != DownloadInfo.NETWORK_OK) { - switch (networkUsable) { - case DownloadInfo.NETWORK_UNUSABLE_DUE_TO_SIZE: - case DownloadInfo.NETWORK_RECOMMENDED_UNUSABLE_DUE_TO_SIZE: - return Downloads.Impl.STATUS_QUEUED_FOR_WIFI; - default: - return Downloads.Impl.STATUS_WAITING_FOR_NETWORK; - } - } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) { - state.mCountRetry = true; - return Downloads.Impl.STATUS_WAITING_TO_RETRY; + private void parseRetryAfterHeaders(State state, HttpURLConnection conn) { + state.mRetryAfter = conn.getHeaderFieldInt("Retry-After", -1); + if (state.mRetryAfter < 0) { + state.mRetryAfter = 0; } else { - Log.w(Constants.TAG, "reached max retries for " + mInfo.mId); - return Downloads.Impl.STATUS_HTTP_DATA_ERROR; + 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; } } @@ -865,8 +742,7 @@ public class DownloadThread extends Thread { * 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 StopRequestException { + private void setupDestinationFile(State state) throws StopRequestException { if (!TextUtils.isEmpty(state.mFilename)) { // only true if we've already run a thread for this download if (Constants.LOGV) { Log.i(Constants.TAG, "have run thread before for id: " + mInfo.mId + @@ -913,15 +789,9 @@ public class DownloadThread extends Thread { Log.i(Constants.TAG, "resuming download for id: " + mInfo.mId + ", and starting with file of length: " + fileLength); } - try { - state.mStream = new FileOutputStream(state.mFilename, true); - } catch (FileNotFoundException exc) { - throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, - "while opening destination for resuming: " + exc.toString(), exc); - } state.mCurrentBytes = (int) fileLength; if (mInfo.mTotalBytes != -1) { - innerState.mHeaderContentLength = Long.toString(mInfo.mTotalBytes); + state.mContentLength = mInfo.mTotalBytes; } state.mHeaderETag = mInfo.mETag; state.mContinuingDownload = true; @@ -933,30 +803,30 @@ public class DownloadThread extends Thread { } } } - - if (state.mStream != null && mInfo.mDestination == Downloads.Impl.DESTINATION_EXTERNAL) { - closeDestination(state); - } } /** * Add custom headers for this download to the HTTP request. */ - private void addRequestHeaders(State state, HttpGet request) { + private void addRequestHeaders(State state, HttpURLConnection conn) { for (Pair<String, String> header : mInfo.getHeaders()) { - request.addHeader(header.first, header.second); + conn.addRequestProperty(header.first, header.second); } + // Only splice in user agent when not already defined + if (conn.getRequestProperty("User-Agent") == null) { + conn.addRequestProperty("User-Agent", userAgent()); + } + + // Defeat transparent gzip compression, since it doesn't allow us to + // easily resume partial downloads. + conn.setRequestProperty("Accept-Encoding", "identity"); + if (state.mContinuingDownload) { if (state.mHeaderETag != null) { - request.addHeader("If-Match", state.mHeaderETag); - } - request.addHeader("Range", "bytes=" + state.mCurrentBytes + "-"); - if (Constants.LOGV) { - Log.i(Constants.TAG, "Adding Range header: " + - "bytes=" + state.mCurrentBytes + "-"); - Log.i(Constants.TAG, " totalBytes = " + state.mTotalBytes); + conn.addRequestProperty("If-Match", state.mHeaderETag); } + conn.addRequestProperty("Range", "bytes=" + state.mCurrentBytes + "-"); } } @@ -964,35 +834,27 @@ public class DownloadThread extends Thread { * Stores information about the completed download, and notifies the initiating application. */ private void notifyDownloadCompleted( - int status, boolean countRetry, int retryAfter, boolean gotData, - String filename, String uri, String mimeType, String errorMsg) { - notifyThroughDatabase( - status, countRetry, retryAfter, gotData, filename, uri, mimeType, - errorMsg); - if (Downloads.Impl.isStatusCompleted(status)) { + State state, int finalStatus, String errorMsg, int numFailed) { + notifyThroughDatabase(state, finalStatus, errorMsg, numFailed); + if (Downloads.Impl.isStatusCompleted(finalStatus)) { mInfo.sendIntentIfRequested(); } } private void notifyThroughDatabase( - int status, boolean countRetry, int retryAfter, boolean gotData, - String filename, String uri, String mimeType, String errorMsg) { + State state, int finalStatus, String errorMsg, int numFailed) { ContentValues values = new ContentValues(); - values.put(Downloads.Impl.COLUMN_STATUS, status); - values.put(Downloads.Impl._DATA, filename); - if (uri != null) { - values.put(Downloads.Impl.COLUMN_URI, uri); - } - values.put(Downloads.Impl.COLUMN_MIME_TYPE, mimeType); + values.put(Downloads.Impl.COLUMN_STATUS, finalStatus); + values.put(Downloads.Impl._DATA, state.mFilename); + values.put(Downloads.Impl.COLUMN_MIME_TYPE, state.mMimeType); values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION, mSystemFacade.currentTimeMillis()); - values.put(Constants.RETRY_AFTER_X_REDIRECT_COUNT, retryAfter); - if (!countRetry) { - values.put(Constants.FAILED_CONNECTIONS, 0); - } else if (gotData) { - values.put(Constants.FAILED_CONNECTIONS, 1); - } else { - values.put(Constants.FAILED_CONNECTIONS, mInfo.mNumFailed + 1); + values.put(Downloads.Impl.COLUMN_FAILED_CONNECTIONS, numFailed); + values.put(Constants.RETRY_AFTER_X_REDIRECT_COUNT, state.mRetryAfter); + + if (!TextUtils.equals(mInfo.mUri, state.mRequestUri)) { + values.put(Downloads.Impl.COLUMN_URI, state.mRequestUri); } + // save the error message. could be useful to developers. if (!TextUtils.isEmpty(errorMsg)) { values.put(Downloads.Impl.COLUMN_ERROR_MSG, errorMsg); @@ -1021,4 +883,27 @@ public class DownloadThread extends Thread { mPolicyDirty = true; } }; + + public static long getHeaderFieldLong(URLConnection conn, String field, long defaultValue) { + try { + return Long.parseLong(conn.getHeaderField(field)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** + * Return if given status is eligible to be treated as + * {@link android.provider.Downloads.Impl#STATUS_WAITING_TO_RETRY}. + */ + public static boolean isStatusRetryable(int status) { + switch (status) { + case STATUS_HTTP_DATA_ERROR: + case HTTP_UNAVAILABLE: + case HTTP_INTERNAL_ERROR: + return true; + default: + return false; + } + } } diff --git a/src/com/android/providers/downloads/DrmConvertSession.java b/src/com/android/providers/downloads/DrmConvertSession.java deleted file mode 100644 index d10edf14..00000000 --- a/src/com/android/providers/downloads/DrmConvertSession.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (C) 2011 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.android.providers.downloads; - -import android.content.Context; -import android.drm.DrmConvertedStatus; -import android.drm.DrmManagerClient; -import android.util.Log; -import android.provider.Downloads; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.RandomAccessFile; - - -public class DrmConvertSession { - private DrmManagerClient mDrmClient; - private int mConvertSessionId; - - private DrmConvertSession(DrmManagerClient drmClient, int convertSessionId) { - mDrmClient = drmClient; - mConvertSessionId = convertSessionId; - } - - /** - * Start of converting a file. - * - * @param context The context of the application running the convert session. - * @param mimeType Mimetype of content that shall be converted. - * @return A convert session or null in case an error occurs. - */ - public static DrmConvertSession open(Context context, String mimeType) { - DrmManagerClient drmClient = null; - int convertSessionId = -1; - if (context != null && mimeType != null && !mimeType.equals("")) { - try { - drmClient = new DrmManagerClient(context); - try { - convertSessionId = drmClient.openConvertSession(mimeType); - } catch (IllegalArgumentException e) { - Log.w(Constants.TAG, "Conversion of Mimetype: " + mimeType - + " is not supported.", e); - } catch (IllegalStateException e) { - Log.w(Constants.TAG, "Could not access Open DrmFramework.", e); - } - } catch (IllegalArgumentException e) { - Log.w(Constants.TAG, - "DrmManagerClient instance could not be created, context is Illegal."); - } catch (IllegalStateException e) { - Log.w(Constants.TAG, "DrmManagerClient didn't initialize properly."); - } - } - - if (drmClient == null || convertSessionId < 0) { - return null; - } else { - return new DrmConvertSession(drmClient, convertSessionId); - } - } - /** - * Convert a buffer of data to protected format. - * - * @param buffer Buffer filled with data to convert. - * @param size The number of bytes that shall be converted. - * @return A Buffer filled with converted data, if execution is ok, in all - * other case null. - */ - public byte [] convert(byte[] inBuffer, int size) { - byte[] result = null; - if (inBuffer != null) { - DrmConvertedStatus convertedStatus = null; - try { - if (size != inBuffer.length) { - byte[] buf = new byte[size]; - System.arraycopy(inBuffer, 0, buf, 0, size); - convertedStatus = mDrmClient.convertData(mConvertSessionId, buf); - } else { - convertedStatus = mDrmClient.convertData(mConvertSessionId, inBuffer); - } - - if (convertedStatus != null && - convertedStatus.statusCode == DrmConvertedStatus.STATUS_OK && - convertedStatus.convertedData != null) { - result = convertedStatus.convertedData; - } - } catch (IllegalArgumentException e) { - Log.w(Constants.TAG, "Buffer with data to convert is illegal. Convertsession: " - + mConvertSessionId, e); - } catch (IllegalStateException e) { - Log.w(Constants.TAG, "Could not convert data. Convertsession: " + - mConvertSessionId, e); - } - } else { - throw new IllegalArgumentException("Parameter inBuffer is null"); - } - return result; - } - - /** - * Ends a conversion session of a file. - * - * @param fileName The filename of the converted file. - * @return Downloads.Impl.STATUS_SUCCESS if execution is ok. - * Downloads.Impl.STATUS_FILE_ERROR in case converted file can not - * be accessed. Downloads.Impl.STATUS_NOT_ACCEPTABLE if a problem - * occurs when accessing drm framework. - * Downloads.Impl.STATUS_UNKNOWN_ERROR if a general error occurred. - */ - public int close(String filename) { - DrmConvertedStatus convertedStatus = null; - int result = Downloads.Impl.STATUS_UNKNOWN_ERROR; - if (mDrmClient != null && mConvertSessionId >= 0) { - try { - convertedStatus = mDrmClient.closeConvertSession(mConvertSessionId); - if (convertedStatus == null || - convertedStatus.statusCode != DrmConvertedStatus.STATUS_OK || - convertedStatus.convertedData == null) { - result = Downloads.Impl.STATUS_NOT_ACCEPTABLE; - } else { - RandomAccessFile rndAccessFile = null; - try { - rndAccessFile = new RandomAccessFile(filename, "rw"); - rndAccessFile.seek(convertedStatus.offset); - rndAccessFile.write(convertedStatus.convertedData); - result = Downloads.Impl.STATUS_SUCCESS; - } catch (FileNotFoundException e) { - result = Downloads.Impl.STATUS_FILE_ERROR; - Log.w(Constants.TAG, "File: " + filename + " could not be found.", e); - } catch (IOException e) { - result = Downloads.Impl.STATUS_FILE_ERROR; - Log.w(Constants.TAG, "Could not access File: " + filename + " .", e); - } catch (IllegalArgumentException e) { - result = Downloads.Impl.STATUS_FILE_ERROR; - Log.w(Constants.TAG, "Could not open file in mode: rw", e); - } catch (SecurityException e) { - Log.w(Constants.TAG, "Access to File: " + filename + - " was denied denied by SecurityManager.", e); - } finally { - if (rndAccessFile != null) { - try { - rndAccessFile.close(); - } catch (IOException e) { - result = Downloads.Impl.STATUS_FILE_ERROR; - Log.w(Constants.TAG, "Failed to close File:" + filename - + ".", e); - } - } - } - } - } catch (IllegalStateException e) { - Log.w(Constants.TAG, "Could not close convertsession. Convertsession: " + - mConvertSessionId, e); - } - } - return result; - } -} diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java index 359f6fa4..33205557 100644 --- a/src/com/android/providers/downloads/Helpers.java +++ b/src/com/android/providers/downloads/Helpers.java @@ -17,10 +17,6 @@ package com.android.providers.downloads; import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.NetworkInfo; import android.net.Uri; import android.os.Environment; import android.os.SystemClock; @@ -29,6 +25,7 @@ import android.util.Log; import android.webkit.MimeTypeMap; import java.io.File; +import java.io.IOException; import java.util.Random; import java.util.Set; import java.util.regex.Matcher; @@ -44,6 +41,8 @@ public class Helpers { private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); + private static final Object sUniqueLock = new Object(); + private Helpers() { } @@ -77,8 +76,10 @@ public class Helpers { String mimeType, int destination, long contentLength, - boolean isPublicApi, StorageManager storageManager) throws StopRequestException { - checkCanHandleDownload(context, mimeType, destination, isPublicApi); + StorageManager storageManager) throws StopRequestException { + if (contentLength < 0) { + contentLength = 0; + } String path; File base = null; if (destination == Downloads.Impl.DESTINATION_FILE_URI) { @@ -90,10 +91,10 @@ public class Helpers { destination); } storageManager.verifySpace(destination, path, contentLength); - path = getFullPath(path, mimeType, destination, base); if (DownloadDrmHelper.isDrmConvertNeeded(mimeType)) { path = DownloadDrmHelper.modifyDrmFwLockFileExtension(path); } + path = getFullPath(path, mimeType, destination, base); return path; } @@ -130,47 +131,20 @@ public class Helpers { if (Constants.LOGVV) { Log.v(Constants.TAG, "target file: " + filename + extension); } - return chooseUniqueFilename(destination, filename, extension, recoveryDir); - } - private static void checkCanHandleDownload(Context context, String mimeType, int destination, - boolean isPublicApi) throws StopRequestException { - if (isPublicApi) { - return; - } + synchronized (sUniqueLock) { + final String path = chooseUniqueFilenameLocked( + destination, filename, extension, recoveryDir); - if (destination == Downloads.Impl.DESTINATION_EXTERNAL - || destination == Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE) { - if (mimeType == null) { - throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE, - "external download with no mime type not allowed"); - } - if (!DownloadDrmHelper.isDrmMimeType(context, mimeType)) { - // Check to see if we are allowed to download this file. Only files - // that can be handled by the platform can be downloaded. - // special case DRM files, which we should always allow downloading. - Intent intent = new Intent(Intent.ACTION_VIEW); - - // We can provide data as either content: or file: URIs, - // so allow both. (I think it would be nice if we just did - // everything as content: URIs) - // Actually, right now the download manager's UId restrictions - // prevent use from using content: so it's got to be file: or - // nothing - - PackageManager pm = context.getPackageManager(); - intent.setDataAndType(Uri.fromParts("file", "", null), mimeType); - ResolveInfo ri = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); - //Log.i(Constants.TAG, "*** FILENAME QUERY " + intent + ": " + list); - - if (ri == null) { - if (Constants.LOGV) { - Log.v(Constants.TAG, "no handler found for type " + mimeType); - } - throw new StopRequestException(Downloads.Impl.STATUS_NOT_ACCEPTABLE, - "no handler found for this download type"); - } + // Claim this filename inside lock to prevent other threads from + // clobbering us. We're not paranoid enough to use O_EXCL. + try { + new File(path).createNewFile(); + } catch (IOException e) { + throw new StopRequestException(Downloads.Impl.STATUS_FILE_ERROR, + "Failed to create target file " + path, e); } + return path; } } @@ -321,7 +295,7 @@ public class Helpers { return extension; } - private static String chooseUniqueFilename(int destination, String filename, + private static String chooseUniqueFilenameLocked(int destination, String filename, String extension, boolean recoveryDir) throws StopRequestException { String fullFilename = filename + extension; if (!new File(fullFilename).exists() @@ -365,14 +339,6 @@ public class Helpers { } /** - * Returns whether the network is available - */ - public static boolean isNetworkAvailable(SystemFacade system, int uid) { - final NetworkInfo info = system.getActiveNetworkInfo(uid); - return info != null && info.isConnected(); - } - - /** * Checks whether the filename looks legitimate */ static boolean isFilenameValid(String filename, File downloadsDataDir) { diff --git a/src/com/android/providers/downloads/OpenHelper.java b/src/com/android/providers/downloads/OpenHelper.java index 5a92316b..184cdb3d 100644 --- a/src/com/android/providers/downloads/OpenHelper.java +++ b/src/com/android/providers/downloads/OpenHelper.java @@ -31,6 +31,8 @@ import android.database.Cursor; import android.net.Uri; import android.provider.Downloads.Impl.RequestHeaders; +import java.io.File; + public class OpenHelper { /** * Build an {@link Intent} to view the download at current {@link Cursor} @@ -48,9 +50,9 @@ public class OpenHelper { } final Uri localUri = getCursorUri(cursor, COLUMN_LOCAL_URI); - final String filename = getCursorString(cursor, COLUMN_LOCAL_FILENAME); + final File file = getCursorFile(cursor, COLUMN_LOCAL_FILENAME); String mimeType = getCursorString(cursor, COLUMN_MEDIA_TYPE); - mimeType = DownloadDrmHelper.getOriginalMimeType(context, filename, mimeType); + mimeType = DownloadDrmHelper.getOriginalMimeType(context, file, mimeType); final Intent intent = new Intent(Intent.ACTION_VIEW); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); @@ -131,4 +133,8 @@ public class OpenHelper { private static long getCursorLong(Cursor cursor, String column) { return cursor.getLong(cursor.getColumnIndexOrThrow(column)); } + + private static File getCursorFile(Cursor cursor, String column) { + return new File(cursor.getString(cursor.getColumnIndexOrThrow(column))); + } } diff --git a/src/com/android/providers/downloads/RealSystemFacade.java b/src/com/android/providers/downloads/RealSystemFacade.java index 228c7165..fa4f3488 100644 --- a/src/com/android/providers/downloads/RealSystemFacade.java +++ b/src/com/android/providers/downloads/RealSystemFacade.java @@ -32,10 +32,12 @@ class RealSystemFacade implements SystemFacade { mContext = context; } + @Override public long currentTimeMillis() { return System.currentTimeMillis(); } + @Override public NetworkInfo getActiveNetworkInfo(int uid) { ConnectivityManager connectivity = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); @@ -57,6 +59,7 @@ class RealSystemFacade implements SystemFacade { return conn.isActiveNetworkMetered(); } + @Override public boolean isNetworkRoaming() { ConnectivityManager connectivity = (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); @@ -74,6 +77,7 @@ class RealSystemFacade implements SystemFacade { return isRoaming; } + @Override public Long getMaxBytesOverMobile() { return DownloadManager.getMaxBytesOverMobile(mContext); } @@ -92,9 +96,4 @@ class RealSystemFacade implements SystemFacade { public boolean userOwnsPackage(int uid, String packageName) throws NameNotFoundException { return mContext.getPackageManager().getApplicationInfo(packageName, 0).uid == uid; } - - @Override - public void startThread(Thread thread) { - thread.start(); - } } diff --git a/src/com/android/providers/downloads/StopRequestException.java b/src/com/android/providers/downloads/StopRequestException.java index 0ccf53cb..a2b642d8 100644 --- a/src/com/android/providers/downloads/StopRequestException.java +++ b/src/com/android/providers/downloads/StopRequestException.java @@ -15,6 +15,9 @@ */ package com.android.providers.downloads; +import static android.provider.Downloads.Impl.STATUS_UNHANDLED_HTTP_CODE; +import static android.provider.Downloads.Impl.STATUS_UNHANDLED_REDIRECT; + /** * Raised to indicate that the current request should be stopped immediately. * @@ -23,15 +26,36 @@ package com.android.providers.downloads; * URI, headers, or destination filename. */ class StopRequestException extends Exception { - public int mFinalStatus; + private final int mFinalStatus; public StopRequestException(int finalStatus, String message) { super(message); mFinalStatus = finalStatus; } - public StopRequestException(int finalStatus, String message, Throwable throwable) { - super(message, throwable); + public StopRequestException(int finalStatus, Throwable t) { + super(t); + mFinalStatus = finalStatus; + } + + public StopRequestException(int finalStatus, String message, Throwable t) { + super(message, t); mFinalStatus = finalStatus; } + + public int getFinalStatus() { + return mFinalStatus; + } + + public static StopRequestException throwUnhandledHttpError(int code, String message) + throws StopRequestException { + final String error = "Unhandled HTTP response: " + code + " " + message; + if (code >= 400 && code < 600) { + throw new StopRequestException(code, error); + } else if (code >= 300 && code < 400) { + throw new StopRequestException(STATUS_UNHANDLED_REDIRECT, error); + } else { + throw new StopRequestException(STATUS_UNHANDLED_HTTP_CODE, error); + } + } } diff --git a/src/com/android/providers/downloads/StorageManager.java b/src/com/android/providers/downloads/StorageManager.java index 915d141b..deb412e7 100644 --- a/src/com/android/providers/downloads/StorageManager.java +++ b/src/com/android/providers/downloads/StorageManager.java @@ -71,12 +71,6 @@ class StorageManager { */ private final File mDownloadDataDir; - /** the Singleton instance of this class. - * TODO: once DownloadService is refactored into a long-living object, there is no need - * for this Singleton'ing. - */ - private static StorageManager sSingleton = null; - /** how often do we need to perform checks on space to make sure space is available */ private static final int FREQUENCY_OF_CHECKS_ON_SPACE_AVAILABILITY = 1024 * 1024; // 1MB private int mBytesDownloadedSinceLastCheckOnSpace = 0; @@ -84,19 +78,9 @@ class StorageManager { /** misc members */ private final Context mContext; - /** - * maintains Singleton instance of this class - */ - synchronized static StorageManager getInstance(Context context) { - if (sSingleton == null) { - sSingleton = new StorageManager(context); - } - return sSingleton; - } - - private StorageManager(Context context) { // constructor is private + public StorageManager(Context context) { mContext = context; - mDownloadDataDir = context.getCacheDir(); + mDownloadDataDir = getDownloadDataDirectory(context); mExternalStorageDir = Environment.getExternalStorageDirectory(); mSystemCacheDir = Environment.getDownloadCacheDirectory(); startThreadToCleanupDatabaseAndPurgeFileSystem(); @@ -308,6 +292,10 @@ class StorageManager { return mDownloadDataDir; } + public static File getDownloadDataDirectory(Context context) { + return context.getCacheDir(); + } + /** * Deletes purgeable files from the cache partition. This also deletes * the matching database entries. Files are deleted in LRU order until @@ -370,7 +358,7 @@ class StorageManager { * This is not a very common occurrence. So, do this only once in a while. */ private void removeSpuriousFiles() { - if (true || Constants.LOGV) { + if (Constants.LOGV) { Log.i(Constants.TAG, "in removeSpuriousFiles"); } // get a list of all files in system cache dir and downloads data dir diff --git a/src/com/android/providers/downloads/SystemFacade.java b/src/com/android/providers/downloads/SystemFacade.java index fda97e08..15fc31f9 100644 --- a/src/com/android/providers/downloads/SystemFacade.java +++ b/src/com/android/providers/downloads/SystemFacade.java @@ -61,9 +61,4 @@ interface SystemFacade { * Returns true if the specified UID owns the specified package name. */ public boolean userOwnsPackage(int uid, String pckg) throws NameNotFoundException; - - /** - * Start a thread. - */ - public void startThread(Thread thread); } diff --git a/tests/Android.mk b/tests/Android.mk index ff3e1d47..655ec168 100644 --- a/tests/Android.mk +++ b/tests/Android.mk @@ -8,7 +8,7 @@ LOCAL_MODULE_TAGS := tests LOCAL_SRC_FILES := $(call all-java-files-under, src) LOCAL_INSTRUMENTATION_FOR := DownloadProvider LOCAL_JAVA_LIBRARIES := android.test.runner -LOCAL_STATIC_JAVA_LIBRARIES := mockwebserver littlemock dexmaker +LOCAL_STATIC_JAVA_LIBRARIES := mockwebserver dexmaker mockito-target LOCAL_PACKAGE_NAME := DownloadProviderTests LOCAL_CERTIFICATE := media diff --git a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java index a65693fa..e59aff08 100644 --- a/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java +++ b/tests/src/com/android/providers/downloads/AbstractDownloadProviderFunctionalTest.java @@ -16,7 +16,7 @@ package com.android.providers.downloads; -import static com.google.testing.littlemock.LittleMock.mock; +import static org.mockito.Mockito.mock; import android.app.NotificationManager; import android.content.ComponentName; @@ -34,6 +34,7 @@ import android.test.mock.MockContentResolver; import android.util.Log; import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockStreamResponse; import com.google.mockwebserver.MockWebServer; import com.google.mockwebserver.RecordedRequest; import com.google.mockwebserver.SocketPolicy; @@ -52,11 +53,11 @@ public abstract class AbstractDownloadProviderFunctionalTest extends protected static final String LOG_TAG = "DownloadProviderFunctionalTest"; private static final String PROVIDER_AUTHORITY = "downloads"; protected static final long RETRY_DELAY_MILLIS = 61 * 1000; - protected static final String FILE_CONTENT = "hello world hello world hello world hello world"; - protected static final int HTTP_OK = 200; - protected static final int HTTP_PARTIAL_CONTENT = 206; - protected static final int HTTP_NOT_FOUND = 404; - protected static final int HTTP_SERVICE_UNAVAILABLE = 503; + + protected static final String + FILE_CONTENT = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + private final MockitoHelper mMockitoHelper = new MockitoHelper(); protected MockWebServer mServer; protected MockContentResolverWithNotify mResolver; @@ -149,6 +150,7 @@ public abstract class AbstractDownloadProviderFunctionalTest extends @Override protected void setUp() throws Exception { super.setUp(); + mMockitoHelper.setUp(getClass()); // Since we're testing a system app, AppDataDirGuesser doesn't find our // cache dir, so set it explicitly. @@ -161,6 +163,7 @@ public abstract class AbstractDownloadProviderFunctionalTest extends setContext(mTestContext); setupService(); getService().mSystemFacade = mSystemFacade; + mSystemFacade.setUp(); assertTrue(isDatabaseEmpty()); // ensure we're not messing with real data mServer = new MockWebServer(); mServer.play(); @@ -170,6 +173,7 @@ public abstract class AbstractDownloadProviderFunctionalTest extends protected void tearDown() throws Exception { cleanUpDownloads(); mServer.shutdown(); + mMockitoHelper.tearDown(); super.tearDown(); } @@ -217,6 +221,10 @@ public abstract class AbstractDownloadProviderFunctionalTest extends mServer.enqueue(resp); } + void enqueueResponse(MockStreamResponse resp) { + mServer.enqueue(resp); + } + MockResponse buildResponse(int status, String body) { return new MockResponse().setResponseCode(status).setBody(body) .setHeader("Content-type", "text/plain") @@ -246,11 +254,6 @@ public abstract class AbstractDownloadProviderFunctionalTest extends return mServer.getUrl(path).toString(); } - public void runService() throws Exception { - startService(null); - mSystemFacade.runAllThreads(); - } - protected String readStream(InputStream inputStream) throws IOException { BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); try { diff --git a/tests/src/com/android/providers/downloads/AbstractPublicApiTest.java b/tests/src/com/android/providers/downloads/AbstractPublicApiTest.java index cda607aa..348dbd1b 100644 --- a/tests/src/com/android/providers/downloads/AbstractPublicApiTest.java +++ b/tests/src/com/android/providers/downloads/AbstractPublicApiTest.java @@ -16,17 +16,22 @@ package com.android.providers.downloads; +import static android.app.DownloadManager.STATUS_FAILED; +import static android.app.DownloadManager.STATUS_SUCCESSFUL; +import static android.text.format.DateUtils.MINUTE_IN_MILLIS; +import static android.text.format.DateUtils.SECOND_IN_MILLIS; + import android.app.DownloadManager; import android.database.Cursor; import android.net.Uri; import android.os.ParcelFileDescriptor; -import android.provider.Downloads; +import android.os.SystemClock; import android.util.Log; -import java.io.FileInputStream; import java.io.InputStream; import java.net.MalformedURLException; import java.net.UnknownHostException; +import java.util.concurrent.TimeoutException; /** * Code common to tests that use the download manager public API. @@ -44,6 +49,10 @@ public abstract class AbstractPublicApiTest extends AbstractDownloadProviderFunc return (int) getLongField(DownloadManager.COLUMN_STATUS); } + public int getReason() { + return (int) getLongField(DownloadManager.COLUMN_REASON); + } + public int getStatusIfExists() { Cursor cursor = mManager.query(new DownloadManager.Query().setFilterById(mId)); try { @@ -86,7 +95,8 @@ public abstract class AbstractPublicApiTest extends AbstractDownloadProviderFunc ParcelFileDescriptor downloadedFile = mManager.openDownloadedFile(mId); assertTrue("Invalid file descriptor: " + downloadedFile, downloadedFile.getFileDescriptor().valid()); - InputStream stream = new FileInputStream(downloadedFile.getFileDescriptor()); + final InputStream stream = new ParcelFileDescriptor.AutoCloseInputStream( + downloadedFile); try { return readStream(stream); } finally { @@ -94,9 +104,52 @@ public abstract class AbstractPublicApiTest extends AbstractDownloadProviderFunc } } - void runUntilStatus(int status) throws Exception { - runService(); - assertEquals(status, getStatus()); + void runUntilStatus(int status) throws TimeoutException { + final long startMillis = mSystemFacade.currentTimeMillis(); + startService(null); + waitForStatus(status, startMillis); + } + + void runUntilStatus(int status, long timeout) throws TimeoutException { + final long startMillis = mSystemFacade.currentTimeMillis(); + startService(null); + waitForStatus(status, startMillis, timeout); + } + + void waitForStatus(int expected, long afterMillis) throws TimeoutException { + waitForStatus(expected, afterMillis, 15 * SECOND_IN_MILLIS); + } + + void waitForStatus(int expected, long afterMillis, long timeout) throws TimeoutException { + int actual = -1; + + final long elapsedTimeout = SystemClock.elapsedRealtime() + timeout; + while (SystemClock.elapsedRealtime() < elapsedTimeout) { + if (getLongField(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP) >= afterMillis) { + actual = getStatus(); + if (actual == STATUS_SUCCESSFUL || actual == STATUS_FAILED) { + assertEquals(expected, actual); + return; + } else if (actual == expected) { + return; + } + + if (timeout > MINUTE_IN_MILLIS) { + final int percent = (int) (100 + * getLongField(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + / getLongField(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); + Log.d(LOG_TAG, percent + "% complete"); + } + } + + if (timeout > MINUTE_IN_MILLIS) { + SystemClock.sleep(SECOND_IN_MILLIS * 3); + } else { + SystemClock.sleep(100); + } + } + + throw new TimeoutException("Expected status " + expected + "; only reached " + actual); } // max time to wait before giving up on the current download operation. @@ -105,22 +158,10 @@ public abstract class AbstractPublicApiTest extends AbstractDownloadProviderFunc // download thread private static final int TIME_TO_SLEEP = 1000; - int runUntilDone() throws InterruptedException { - int sleepCounter = MAX_TIME_TO_WAIT_FOR_OPERATION * 1000 / TIME_TO_SLEEP; - for (int i = 0; i < sleepCounter; i++) { - int status = getStatusIfExists(); - if (status == -1 || Downloads.Impl.isStatusCompleted(getStatus())) { - // row doesn't exist or the download is done - return status; - } - // download not done yet. sleep a while and try again - Thread.sleep(TIME_TO_SLEEP); - } - return 0; // failed - } - // waits until progress_so_far is >= (progress)% boolean runUntilProgress(int progress) throws InterruptedException { + startService(null); + int sleepCounter = MAX_TIME_TO_WAIT_FOR_OPERATION * 1000 / TIME_TO_SLEEP; int numBytesReceivedSoFar = 0; int totalBytes = 0; diff --git a/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java b/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java index 23d300f8..dbab203c 100644 --- a/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java +++ b/tests/src/com/android/providers/downloads/DownloadProviderFunctionalTest.java @@ -16,14 +16,17 @@ package com.android.providers.downloads; +import static android.text.format.DateUtils.SECOND_IN_MILLIS; +import static java.net.HttpURLConnection.HTTP_OK; + import android.content.ContentValues; import android.database.Cursor; import android.net.ConnectivityManager; import android.net.Uri; import android.os.Environment; +import android.os.SystemClock; import android.provider.Downloads; import android.test.suitebuilder.annotation.LargeTest; -import android.util.Log; import com.google.mockwebserver.MockWebServer; import com.google.mockwebserver.RecordedRequest; @@ -31,6 +34,7 @@ import com.google.mockwebserver.RecordedRequest; import java.io.InputStream; import java.net.MalformedURLException; import java.net.UnknownHostException; +import java.util.concurrent.TimeoutException; /** * This test exercises the entire download manager working together -- it requests downloads through @@ -109,20 +113,22 @@ public class DownloadProviderFunctionalTest extends AbstractDownloadProviderFunc } } - private void runUntilStatus(Uri downloadUri, int status) throws Exception { - runService(); - boolean done = false; - while (!done) { - int rslt = getDownloadStatus(downloadUri); - if (rslt == Downloads.Impl.STATUS_RUNNING || rslt == Downloads.Impl.STATUS_PENDING) { - Log.i(TAG, "status is: " + rslt + ", for: " + downloadUri); - DownloadHandler.getInstance().waitUntilDownloadsTerminate(); - Thread.sleep(100); - } else { - done = true; + private void runUntilStatus(Uri downloadUri, int expected) throws Exception { + startService(null); + + int actual = -1; + + final long timeout = SystemClock.elapsedRealtime() + (15 * SECOND_IN_MILLIS); + while (SystemClock.elapsedRealtime() < timeout) { + actual = getDownloadStatus(downloadUri); + if (expected == actual) { + return; } + + SystemClock.sleep(100); } - assertEquals(status, getDownloadStatus(downloadUri)); + + throw new TimeoutException("Expected status " + expected + "; only reached " + actual); } protected int getDownloadStatus(Uri downloadUri) { diff --git a/tests/src/com/android/providers/downloads/FakeInputStream.java b/tests/src/com/android/providers/downloads/FakeInputStream.java new file mode 100644 index 00000000..179ae6e9 --- /dev/null +++ b/tests/src/com/android/providers/downloads/FakeInputStream.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.downloads; + +import java.io.InputStream; +import java.util.Arrays; + +/** + * Provides fake data for large transfers. + */ +public class FakeInputStream extends InputStream { + private long mRemaining; + + public FakeInputStream(long length) { + mRemaining = length; + } + + @Override + public int read() { + final int value; + if (mRemaining > 0) { + mRemaining--; + return 0; + } else { + return -1; + } + } + + @Override + public int read(byte[] buffer, int offset, int length) { + Arrays.checkOffsetAndCount(buffer.length, offset, length); + + if (length > mRemaining) { + length = (int) mRemaining; + } + mRemaining -= length; + + if (length == 0) { + return -1; + } else { + return length; + } + } +} diff --git a/tests/src/com/android/providers/downloads/FakeSystemFacade.java b/tests/src/com/android/providers/downloads/FakeSystemFacade.java index 481b5cba..d54c1224 100644 --- a/tests/src/com/android/providers/downloads/FakeSystemFacade.java +++ b/tests/src/com/android/providers/downloads/FakeSystemFacade.java @@ -7,10 +7,7 @@ import android.net.NetworkInfo; import android.net.NetworkInfo.DetailedState; import java.util.ArrayList; -import java.util.LinkedList; import java.util.List; -import java.util.Queue; - public class FakeSystemFacade implements SystemFacade { long mTimeMillis = 0; Integer mActiveNetworkType = ConnectivityManager.TYPE_WIFI; @@ -19,20 +16,32 @@ public class FakeSystemFacade implements SystemFacade { Long mMaxBytesOverMobile = null; Long mRecommendedMaxBytesOverMobile = null; List<Intent> mBroadcastsSent = new ArrayList<Intent>(); - Queue<Thread> mStartedThreads = new LinkedList<Thread>(); - private boolean returnActualTime = false; + private boolean mReturnActualTime = false; + + public void setUp() { + mTimeMillis = 0; + mActiveNetworkType = ConnectivityManager.TYPE_WIFI; + mIsRoaming = false; + mIsMetered = false; + mMaxBytesOverMobile = null; + mRecommendedMaxBytesOverMobile = null; + mBroadcastsSent.clear(); + mReturnActualTime = false; + } void incrementTimeMillis(long delta) { mTimeMillis += delta; } + @Override public long currentTimeMillis() { - if (returnActualTime) { + if (mReturnActualTime) { return System.currentTimeMillis(); } return mTimeMillis; } + @Override public NetworkInfo getActiveNetworkInfo(int uid) { if (mActiveNetworkType == null) { return null; @@ -48,14 +57,17 @@ public class FakeSystemFacade implements SystemFacade { return mIsMetered; } + @Override public boolean isNetworkRoaming() { return mIsRoaming; } + @Override public Long getMaxBytesOverMobile() { return mMaxBytesOverMobile ; } + @Override public Long getRecommendedMaxBytesOverMobile() { return mRecommendedMaxBytesOverMobile ; } @@ -70,27 +82,7 @@ public class FakeSystemFacade implements SystemFacade { return true; } - public boolean startThreadsWithoutWaiting = false; - public void setStartThreadsWithoutWaiting(boolean flag) { - this.startThreadsWithoutWaiting = flag; - } - - @Override - public void startThread(Thread thread) { - if (startThreadsWithoutWaiting) { - thread.start(); - } else { - mStartedThreads.add(thread); - } - } - - public void runAllThreads() { - while (!mStartedThreads.isEmpty()) { - mStartedThreads.poll().run(); - } - } - public void setReturnActualTime(boolean flag) { - returnActualTime = flag; + mReturnActualTime = flag; } } diff --git a/tests/src/com/android/providers/downloads/MockitoHelper.java b/tests/src/com/android/providers/downloads/MockitoHelper.java new file mode 100644 index 00000000..485128d8 --- /dev/null +++ b/tests/src/com/android/providers/downloads/MockitoHelper.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.downloads; + +import android.util.Log; + +/** + * Helper for Mockito-based test cases. + */ +public final class MockitoHelper { + private static final String TAG = "MockitoHelper"; + + private ClassLoader mOriginalClassLoader; + private Thread mContextThread; + + /** + * Creates a new helper, which in turn will set the context classloader so + * it can load Mockito resources. + * + * @param packageClass test case class + */ + public void setUp(Class<?> packageClass) throws Exception { + // makes a copy of the context classloader + mContextThread = Thread.currentThread(); + mOriginalClassLoader = mContextThread.getContextClassLoader(); + ClassLoader newClassLoader = packageClass.getClassLoader(); + Log.v(TAG, "Changing context classloader from " + mOriginalClassLoader + + " to " + newClassLoader); + mContextThread.setContextClassLoader(newClassLoader); + } + + /** + * Restores the context classloader to the previous value. + */ + public void tearDown() throws Exception { + Log.v(TAG, "Restoring context classloader to " + mOriginalClassLoader); + mContextThread.setContextClassLoader(mOriginalClassLoader); + } +} diff --git a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java index 2661a1f2..b6fd611e 100644 --- a/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java +++ b/tests/src/com/android/providers/downloads/PublicApiFunctionalTest.java @@ -16,13 +16,23 @@ package com.android.providers.downloads; -import static com.google.testing.littlemock.LittleMock.anyInt; -import static com.google.testing.littlemock.LittleMock.anyString; -import static com.google.testing.littlemock.LittleMock.atLeastOnce; -import static com.google.testing.littlemock.LittleMock.isA; -import static com.google.testing.littlemock.LittleMock.never; -import static com.google.testing.littlemock.LittleMock.times; -import static com.google.testing.littlemock.LittleMock.verify; +import static android.app.DownloadManager.STATUS_FAILED; +import static android.app.DownloadManager.STATUS_PAUSED; +import static android.net.TrafficStats.GB_IN_BYTES; +import static android.text.format.DateUtils.SECOND_IN_MILLIS; +import static java.net.HttpURLConnection.HTTP_MOVED_TEMP; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_PARTIAL; +import static java.net.HttpURLConnection.HTTP_PRECON_FAILED; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.isA; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import android.app.DownloadManager; import android.app.Notification; @@ -33,20 +43,24 @@ import android.database.Cursor; import android.net.ConnectivityManager; import android.net.Uri; import android.os.Environment; +import android.os.SystemClock; import android.provider.Downloads; import android.test.suitebuilder.annotation.LargeTest; +import android.test.suitebuilder.annotation.Suppress; +import android.text.format.DateUtils; import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockStreamResponse; import com.google.mockwebserver.RecordedRequest; +import com.google.mockwebserver.SocketPolicy; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.net.MalformedURLException; import java.util.List; - +import java.util.concurrent.TimeoutException; @LargeTest public class PublicApiFunctionalTest extends AbstractPublicApiTest { @@ -76,7 +90,6 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { } else { mTestDirectory.mkdir(); } - mSystemFacade.setStartThreadsWithoutWaiting(false); } @Override @@ -122,6 +135,24 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { checkCompleteDownload(download); } + @Suppress + public void testExtremelyLarge() throws Exception { + // NOTE: suppressed since this takes several minutes to run + final long length = 3 * GB_IN_BYTES; + final InputStream body = new FakeInputStream(length); + + enqueueResponse(new MockStreamResponse().setResponseCode(HTTP_OK).setBody(body, length) + .setHeader("Content-type", "text/plain") + .setSocketPolicy(SocketPolicy.DISCONNECT_AT_END)); + + final Download download = enqueueRequest(getRequest() + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "extreme.bin")); + download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL, 10 * DateUtils.MINUTE_IN_MILLIS); + + assertEquals(length, download.getLongField(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); + assertEquals(length, download.getLongField(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); + } + private void checkUriContent(Uri uri) throws FileNotFoundException, IOException { InputStream inputStream = mResolver.openInputStream(uri); try { @@ -191,7 +222,7 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { private MockResponse buildPartialResponse(int start, int end) { int totalLength = FILE_CONTENT.length(); boolean isFirstResponse = (start == 0); - int status = isFirstResponse ? HTTP_OK : HTTP_PARTIAL_CONTENT; + int status = isFirstResponse ? HTTP_OK : HTTP_PARTIAL; MockResponse response = buildResponse(status, FILE_CONTENT.substring(start, end)) .setHeader("Content-length", totalLength) .setHeader("Etag", ETAG); @@ -385,11 +416,72 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { assertEquals(REQUEST_PATH, lastRequest.getPath()); } + public void testRunawayRedirect() throws Exception { + for (int i = 0; i < 16; i++) { + enqueueResponse(buildEmptyResponse(HTTP_MOVED_TEMP) + .setHeader("Location", mServer.getUrl("/" + i).toString())); + } + + final Download download = enqueueRequest(getRequest()); + + // Ensure that we arrive at failed download, instead of spinning forever + download.runUntilStatus(DownloadManager.STATUS_FAILED); + assertEquals(DownloadManager.ERROR_TOO_MANY_REDIRECTS, download.getReason()); + } + + public void testRunawayUnavailable() throws Exception { + final int RETRY_DELAY = 120; + for (int i = 0; i < 16; i++) { + enqueueResponse( + buildEmptyResponse(HTTP_UNAVAILABLE).setHeader("Retry-after", RETRY_DELAY)); + } + + final Download download = enqueueRequest(getRequest()); + for (int i = 0; i < Constants.MAX_RETRIES - 1; i++) { + download.runUntilStatus(DownloadManager.STATUS_PAUSED); + mSystemFacade.incrementTimeMillis((RETRY_DELAY + 60) * SECOND_IN_MILLIS); + } + + // Ensure that we arrive at failed download, instead of spinning forever + download.runUntilStatus(DownloadManager.STATUS_FAILED); + } + public void testNoEtag() throws Exception { enqueueResponse(buildPartialResponse(0, 5).removeHeader("Etag")); runSimpleFailureTest(DownloadManager.ERROR_CANNOT_RESUME); } + public void testEtagChanged() throws Exception { + final String A = "kittenz"; + final String B = "puppiez"; + + // 1. Try downloading A, but partial result + enqueueResponse(buildResponse(HTTP_OK, A.substring(0, 2)) + .setHeader("Content-length", A.length()) + .setHeader("Etag", A)); + + // 2. Try resuming A, but fail ETag check + enqueueResponse(buildEmptyResponse(HTTP_PRECON_FAILED)); + + final Download download = enqueueRequest(getRequest()); + RecordedRequest req; + + // 1. Try downloading A, but partial result + download.runUntilStatus(STATUS_PAUSED); + assertEquals(DownloadManager.PAUSED_WAITING_TO_RETRY, download.getReason()); + req = takeRequest(); + assertNull(getHeaderValue(req, "Range")); + assertNull(getHeaderValue(req, "If-Match")); + + // 2. Try resuming A, but fail ETag check + mSystemFacade.incrementTimeMillis(RETRY_DELAY_MILLIS); + download.runUntilStatus(STATUS_FAILED); + assertEquals(HTTP_PRECON_FAILED, download.getReason()); + req = takeRequest(); + assertEquals("bytes=2-", getHeaderValue(req, "Range")); + assertEquals(A, getHeaderValue(req, "If-Match")); + } + public void testSanitizeMediaType() throws Exception { enqueueResponse(buildEmptyResponse(HTTP_OK) .setHeader("Content-Type", "text/html; charset=ISO-8859-4")); @@ -400,7 +492,7 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { public void testNoContentLength() throws Exception { enqueueResponse(buildEmptyResponse(HTTP_OK).removeHeader("Content-length")); - runSimpleFailureTest(DownloadManager.ERROR_HTTP_DATA_ERROR); + runSimpleFailureTest(DownloadManager.ERROR_CANNOT_RESUME); } public void testInsufficientSpace() throws Exception { @@ -412,22 +504,24 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { } public void testCancel() throws Exception { - mSystemFacade.setStartThreadsWithoutWaiting(true); // return 'real time' from FakeSystemFacade so that DownloadThread will report progress mSystemFacade.setReturnActualTime(true); enqueueResponse(buildContinuingResponse()); Download download = enqueueRequest(getRequest()); - startService(null); // give the download time to get started and progress to 1% completion // before cancelling it. boolean rslt = download.runUntilProgress(1); assertTrue(rslt); mManager.remove(download.mId); - startService(null); - int status = download.runUntilDone(); - // make sure the row is gone from the database - assertEquals(-1, status); - mSystemFacade.setReturnActualTime(false); + + // Verify that row is removed from database + final long timeout = SystemClock.elapsedRealtime() + (15 * SECOND_IN_MILLIS); + while (download.getStatusIfExists() != -1) { + if (SystemClock.elapsedRealtime() > timeout) { + throw new TimeoutException("Row wasn't removed"); + } + SystemClock.sleep(100); + } } public void testDownloadCompleteBroadcast() throws Exception { @@ -512,9 +606,9 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { public void testContentObserver() throws Exception { enqueueResponse(buildEmptyResponse(HTTP_OK)); - enqueueRequest(getRequest()); mResolver.resetNotified(); - runService(); + final Download download = enqueueRequest(getRequest()); + download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); assertTrue(mResolver.mNotifyWasCalled); } @@ -524,10 +618,9 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { final Download download = enqueueRequest( getRequest().setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)); download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); - runService(); + verify(mNotifManager, times(1)).cancelAll(); verify(mNotifManager, never()).notify(anyString(), anyInt(), isA(Notification.class)); - // TODO: verify that it never cancels } public void testNotificationVisible() throws Exception { @@ -536,11 +629,10 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { // only shows in-progress notifications final Download download = enqueueRequest(getRequest()); download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); - runService(); // TODO: verify different notif types with tags + verify(mNotifManager, times(1)).cancelAll(); verify(mNotifManager, atLeastOnce()).notify(anyString(), anyInt(), isA(Notification.class)); - verify(mNotifManager, times(1)).cancel(anyString(), anyInt()); } public void testNotificationVisibleComplete() throws Exception { @@ -549,17 +641,16 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { final Download download = enqueueRequest(getRequest().setNotificationVisibility( DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)); download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); - runService(); // TODO: verify different notif types with tags + verify(mNotifManager, times(1)).cancelAll(); verify(mNotifManager, atLeastOnce()).notify(anyString(), anyInt(), isA(Notification.class)); - verify(mNotifManager, times(1)).cancel(anyString(), anyInt()); } public void testRetryAfter() throws Exception { final int delay = 120; enqueueResponse( - buildEmptyResponse(HTTP_SERVICE_UNAVAILABLE).setHeader("Retry-after", delay)); + buildEmptyResponse(HTTP_UNAVAILABLE).setHeader("Retry-after", delay)); enqueueResponse(buildEmptyResponse(HTTP_OK)); Download download = enqueueRequest(getRequest()); @@ -643,19 +734,32 @@ public class PublicApiFunctionalTest extends AbstractPublicApiTest { * 3) Resume request to complete download * @return the last request sent to the server, resuming after the interruption */ - private RecordedRequest runRedirectionTest(int status) - throws MalformedURLException, Exception { + private RecordedRequest runRedirectionTest(int status) throws Exception { enqueueResponse(buildEmptyResponse(status) .setHeader("Location", mServer.getUrl(REDIRECTED_PATH).toString())); enqueueInterruptedDownloadResponses(5); - Download download = enqueueRequest(getRequest()); - runService(); + final Download download = enqueueRequest(getRequest()); + download.runUntilStatus(DownloadManager.STATUS_PAUSED); + mSystemFacade.incrementTimeMillis(RETRY_DELAY_MILLIS); + download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); + assertEquals(REQUEST_PATH, takeRequest().getPath()); assertEquals(REDIRECTED_PATH, takeRequest().getPath()); - mSystemFacade.incrementTimeMillis(RETRY_DELAY_MILLIS); - download.runUntilStatus(DownloadManager.STATUS_SUCCESSFUL); return takeRequest(); } + + /** + * Return value of requested HTTP header, if it exists. + */ + private static String getHeaderValue(RecordedRequest req, String header) { + header = header.toLowerCase() + ":"; + for (String h : req.getHeaders()) { + if (h.toLowerCase().startsWith(header)) { + return h.substring(header.length()).trim(); + } + } + return null; + } } diff --git a/tests/src/com/android/providers/downloads/ThreadingTest.java b/tests/src/com/android/providers/downloads/ThreadingTest.java index 8605c76b..920f703b 100644 --- a/tests/src/com/android/providers/downloads/ThreadingTest.java +++ b/tests/src/com/android/providers/downloads/ThreadingTest.java @@ -16,23 +16,27 @@ package com.android.providers.downloads; +import static java.net.HttpURLConnection.HTTP_OK; + import android.app.DownloadManager; import android.test.suitebuilder.annotation.LargeTest; +import android.util.Pair; + +import com.google.android.collect.Lists; +import com.google.android.collect.Sets; +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.SocketPolicy; + +import java.util.List; +import java.util.Set; /** * Download manager tests that require multithreading. */ @LargeTest public class ThreadingTest extends AbstractPublicApiTest { - private static class FakeSystemFacadeWithThreading extends FakeSystemFacade { - @Override - public void startThread(Thread thread) { - thread.start(); - } - } - public ThreadingTest() { - super(new FakeSystemFacadeWithThreading()); + super(new FakeSystemFacade()); } @Override @@ -53,4 +57,41 @@ public class ThreadingTest extends AbstractPublicApiTest { Thread.sleep(10); } } + + public void testFilenameRace() throws Exception { + final List<Pair<Download, String>> downloads = Lists.newArrayList(); + + // Request dozen files at once with same name + for (int i = 0; i < 12; i++) { + final String body = "DOWNLOAD " + i + " CONTENTS"; + enqueueResponse(new MockResponse().setResponseCode(HTTP_OK).setBody(body) + .setHeader("Content-type", "text/plain") + .setSocketPolicy(SocketPolicy.DISCONNECT_AT_END)); + + final Download d = enqueueRequest(getRequest()); + downloads.add(Pair.create(d, body)); + } + + // Kick off downloads in parallel + final long startMillis = mSystemFacade.currentTimeMillis(); + startService(null); + + for (Pair<Download,String> d : downloads) { + d.first.waitForStatus(DownloadManager.STATUS_SUCCESSFUL, startMillis); + } + + // Ensure that contents are clean and filenames unique + final Set<String> seenFiles = Sets.newHashSet(); + + for (Pair<Download, String> d : downloads) { + final String file = d.first.getStringField(DownloadManager.COLUMN_LOCAL_FILENAME); + if (!seenFiles.add(file)) { + fail("Another download already claimed " + file); + } + + final String expected = d.second; + final String actual = d.first.getContents(); + assertEquals(expected, actual); + } + } } diff --git a/ui/AndroidManifest.xml b/ui/AndroidManifest.xml index 04d18635..f707dfbd 100644 --- a/ui/AndroidManifest.xml +++ b/ui/AndroidManifest.xml @@ -9,11 +9,13 @@ <application android:process="android.process.media" android:label="@string/app_label" android:icon="@mipmap/ic_launcher_download" - android:hardwareAccelerated="true"> + android:hardwareAccelerated="true" + android:supportsRtl="true" + android:requiredForAllUsers="true"> + <activity android:name=".DownloadList" android:launchMode="singleTop" android:theme="@android:style/Theme.Holo.DialogWhenLarge"> - <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> diff --git a/ui/res/layout/download_list.xml b/ui/res/layout/download_list.xml index e4ebf7c9..a0ff5ff2 100644 --- a/ui/res/layout/download_list.xml +++ b/ui/res/layout/download_list.xml @@ -30,16 +30,16 @@ android:layout_weight="1"> <ExpandableListView android:id="@+id/date_ordered_list" - android:paddingLeft="16dip" - android:paddingRight="16dip" + android:paddingStart="16dip" + android:paddingEnd="16dip" android:paddingBottom="16dip" android:clipToPadding="false" android:layout_width="match_parent" android:layout_height="match_parent" android:scrollbarStyle="outsideOverlay" /> <ListView android:id="@+id/size_ordered_list" - android:paddingLeft="16dip" - android:paddingRight="16dip" + android:paddingStart="16dip" + android:paddingEnd="16dip" android:paddingBottom="16dip" android:clipToPadding="false" android:layout_width="match_parent" diff --git a/ui/res/layout/download_list_item.xml b/ui/res/layout/download_list_item.xml index e5759d5d..2435ba7f 100644 --- a/ui/res/layout/download_list_item.xml +++ b/ui/res/layout/download_list_item.xml @@ -21,8 +21,9 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" - android:paddingRight="?android:attr/listPreferredItemPaddingRight" + android:minHeight="?android:attr/listPreferredItemHeight" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:paddingTop="8dip" android:paddingBottom="8dip" android:columnCount="4" @@ -40,9 +41,10 @@ android:layout_width="@android:dimen/app_icon_size" android:layout_height="@android:dimen/app_icon_size" android:layout_rowSpan="3" - android:layout_marginRight="8dip" + android:layout_marginEnd="8dip" android:layout_gravity="center_vertical" - android:scaleType="centerInside" /> + android:scaleType="centerInside" + android:contentDescription="@null" /> <TextView android:id="@+id/download_title" @@ -52,7 +54,8 @@ android:singleLine="true" android:ellipsize="marquee" android:textStyle="bold" - android:textAppearance="?android:attr/textAppearance" /> + android:textAppearance="?android:attr/textAppearance" + android:textAlignment="viewStart" /> <TextView android:id="@+id/domain" @@ -61,7 +64,8 @@ android:layout_gravity="fill_horizontal" android:singleLine="true" android:ellipsize="marquee" - android:textAppearance="?android:attr/textAppearanceSmall" /> + android:textAppearance="?android:attr/textAppearanceSmall" + android:textAlignment="viewStart" /> <TextView android:id="@+id/size_text" @@ -69,11 +73,13 @@ android:layout_gravity="fill_horizontal" android:singleLine="true" android:ellipsize="marquee" - android:textAppearance="?android:attr/textAppearanceSmall" /> + android:textAppearance="?android:attr/textAppearanceSmall" + android:textAlignment="viewStart" /> <TextView android:id="@+id/status_text" - android:layout_marginLeft="8dip" - android:textAppearance="?android:attr/textAppearanceSmall" /> + android:layout_marginStart="8dip" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textAlignment="viewStart" /> </com.android.providers.downloads.ui.DownloadItem> diff --git a/ui/res/layout/list_group_header.xml b/ui/res/layout/list_group_header.xml index 2600f8de..466cd6c6 100644 --- a/ui/res/layout/list_group_header.xml +++ b/ui/res/layout/list_group_header.xml @@ -15,10 +15,9 @@ --> <TextView xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:minHeight="?android:attr/listPreferredItemHeight" - android:textAppearance="?android:attr/textAppearanceMedium" - android:paddingLeft="43dip" - android:layout_gravity="center_vertical" - android:gravity="center_vertical"/> + android:id="@android:id/text1" + android:layout_width="match_parent" + android:layout_height="?android:attr/listPreferredItemHeight" + android:paddingStart="?android:attr/expandableListPreferredItemPaddingLeft" + android:textAppearance="?android:attr/textAppearanceMedium" + android:gravity="center_vertical" /> diff --git a/ui/res/menu/download_menu.xml b/ui/res/menu/download_menu.xml index fc578920..a33dd52b 100644 --- a/ui/res/menu/download_menu.xml +++ b/ui/res/menu/download_menu.xml @@ -17,9 +17,11 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/share_download" android:icon="@android:drawable/ic_menu_share" + android:title="@string/download_share_dialog" android:showAsAction="always" /> <item android:id="@+id/delete_download" android:icon="@android:drawable/ic_menu_delete" + android:title="@string/delete_download" android:showAsAction="always" /> </menu> diff --git a/ui/res/values-ca/strings.xml b/ui/res/values-ca/strings.xml index 43a8e38e..072ca539 100644 --- a/ui/res/values-ca/strings.xml +++ b/ui/res/values-ca/strings.xml @@ -31,7 +31,7 @@ <string name="dialog_failed_body" msgid="587545111677064427">"Vols tornar a intentar baixar el fitxer més tard o vols suprimir-lo de la cua?"</string> <string name="dialog_title_queued_body" msgid="6760681913815015219">"Fitxer en cua"</string> <string name="dialog_queued_body" msgid="708552801635572720">"Aquest fitxer està en cua per baixar més endavant, per tant, encara no està disponible."</string> - <string name="dialog_file_missing_body" msgid="3223012612774276284">"No es troba el fitxer que s\'ha baixat."</string> + <string name="dialog_file_missing_body" msgid="3223012612774276284">"No es pot trobar el fitxer baixat."</string> <string name="dialog_insufficient_space_on_external" msgid="8692452156251449195">"No es pot finalitzar la baixada. No hi ha prou espai a l\'emmagatzematge extern."</string> <string name="dialog_insufficient_space_on_cache" msgid="6313630206163908994">"No es pot finalitzar la baixada. No hi ha prou espai a l\'emmagatzematge intern."</string> <string name="dialog_cannot_resume" msgid="8664509751358983543">"S\'ha interromput la baixada i no es pot reprendre."</string> diff --git a/ui/res/values-es/strings.xml b/ui/res/values-es/strings.xml index 5c724609..37903dca 100644 --- a/ui/res/values-es/strings.xml +++ b/ui/res/values-es/strings.xml @@ -45,6 +45,6 @@ <string name="retry_download" msgid="7617100787922717912">"Reintentar"</string> <string name="deselect_all" msgid="6348198946254776764">"Desmarcar todo"</string> <string name="select_all" msgid="634074918366265804">"Seleccionar todo"</string> - <string name="selected_count" msgid="2101564570019753277">"Has seleccionado <xliff:g id="NUMBER">%1$d</xliff:g> de <xliff:g id="TOTAL">%2$d</xliff:g>."</string> + <string name="selected_count" msgid="2101564570019753277">"Elegido: <xliff:g id="NUMBER">%1$d</xliff:g> de <xliff:g id="TOTAL">%2$d</xliff:g>"</string> <string name="download_share_dialog" msgid="3355867339806448955">"Compartir a través de"</string> </resources> diff --git a/ui/res/values-fa/strings.xml b/ui/res/values-fa/strings.xml index fd81dd26..8650ce35 100644 --- a/ui/res/values-fa/strings.xml +++ b/ui/res/values-fa/strings.xml @@ -21,7 +21,7 @@ <string name="download_title_sorted_by_size" msgid="1417193166677094813">"دانلودها - مرتب شده بر اساس اندازه"</string> <string name="no_downloads" msgid="1029667411186146836">"خیر دانلودها."</string> <string name="missing_title" msgid="830115697868833773">"<ناشناس>"</string> - <string name="button_sort_by_size" msgid="7331549713691146251">"ترتیب بر اساس اندازه"</string> + <string name="button_sort_by_size" msgid="7331549713691146251">"بر اساس اندازه مرتب شود"</string> <string name="button_sort_by_date" msgid="8800842892684101528">"ترتیب براساس تاریخ"</string> <string name="download_queued" msgid="104973307780629904">"در صف"</string> <string name="download_running" msgid="4656462962155580641">"در حال انجام"</string> diff --git a/ui/res/values-hi/strings.xml b/ui/res/values-hi/strings.xml index 8e36dcc8..4761bd34 100644 --- a/ui/res/values-hi/strings.xml +++ b/ui/res/values-hi/strings.xml @@ -46,5 +46,5 @@ <string name="deselect_all" msgid="6348198946254776764">"सभी का चयन रद्द करें"</string> <string name="select_all" msgid="634074918366265804">"सभी चुनें"</string> <string name="selected_count" msgid="2101564570019753277">"<xliff:g id="TOTAL">%2$d</xliff:g> में से <xliff:g id="NUMBER">%1$d</xliff:g> चयनित"</string> - <string name="download_share_dialog" msgid="3355867339806448955">"इसके द्वारा शेयर करें"</string> + <string name="download_share_dialog" msgid="3355867339806448955">"इसके द्वारा साझा करें"</string> </resources> diff --git a/ui/res/values/dimen.xml b/ui/res/values/dimen.xml index 6e48f132..7519b878 100644 --- a/ui/res/values/dimen.xml +++ b/ui/res/values/dimen.xml @@ -15,5 +15,5 @@ --> <resources> - <dimen name="checkmark_area">40dip</dimen> + <dimen name="checkmark_area">48dip</dimen> </resources> diff --git a/ui/src/com/android/providers/downloads/ui/DateSortedExpandableListAdapter.java b/ui/src/com/android/providers/downloads/ui/DateSortedExpandableListAdapter.java index 19132a11..f5d70770 100644 --- a/ui/src/com/android/providers/downloads/ui/DateSortedExpandableListAdapter.java +++ b/ui/src/com/android/providers/downloads/ui/DateSortedExpandableListAdapter.java @@ -273,7 +273,7 @@ public class DateSortedExpandableListAdapter implements ExpandableListAdapter { TextView item; if (null == convertView || !(convertView instanceof TextView)) { LayoutInflater factory = LayoutInflater.from(mContext); - item = (TextView) factory.inflate(R.layout.list_group_header, null); + item = (TextView) factory.inflate(R.layout.list_group_header, parent, false); } else { item = (TextView) convertView; } diff --git a/ui/src/com/android/providers/downloads/ui/DownloadItem.java b/ui/src/com/android/providers/downloads/ui/DownloadItem.java index e24ac4a4..0562cd05 100644 --- a/ui/src/com/android/providers/downloads/ui/DownloadItem.java +++ b/ui/src/com/android/providers/downloads/ui/DownloadItem.java @@ -18,12 +18,11 @@ package com.android.providers.downloads.ui; import android.content.Context; import android.util.AttributeSet; -import android.view.accessibility.AccessibilityEvent; import android.view.MotionEvent; +import android.view.accessibility.AccessibilityEvent; import android.widget.CheckBox; import android.widget.Checkable; import android.widget.GridLayout; -import android.widget.RelativeLayout; /** * This class customizes RelativeLayout to directly handle clicks on the left part of the view and @@ -83,12 +82,20 @@ public class DownloadItem extends GridLayout implements Checkable { mDownloadList = downloadList; } + private boolean inCheckArea(MotionEvent event) { + if (isLayoutRtl()) { + return event.getX() > getWidth() - CHECKMARK_AREA; + } else { + return event.getX() < CHECKMARK_AREA; + } + } + @Override public boolean onTouchEvent(MotionEvent event) { boolean handled = false; switch(event.getAction()) { case MotionEvent.ACTION_DOWN: - if (event.getX() < CHECKMARK_AREA) { + if (inCheckArea(event)) { mIsInDownEvent = true; handled = true; } @@ -99,7 +106,7 @@ public class DownloadItem extends GridLayout implements Checkable { break; case MotionEvent.ACTION_UP: - if (mIsInDownEvent && event.getX() < CHECKMARK_AREA) { + if (mIsInDownEvent && inCheckArea(event)) { toggle(); sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED); handled = true; diff --git a/ui/src/com/android/providers/downloads/ui/DownloadList.java b/ui/src/com/android/providers/downloads/ui/DownloadList.java index ed369932..fd000d3c 100644 --- a/ui/src/com/android/providers/downloads/ui/DownloadList.java +++ b/ui/src/com/android/providers/downloads/ui/DownloadList.java @@ -732,7 +732,9 @@ public class DownloadList extends Activity { Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, item.getKey()); final String mimeType = item.getValue().getMimeType(); attachments.add(uri); - mimeTypes.add(mimeType); + if (mimeType != null) { + mimeTypes.add(mimeType); + } } intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments); intent.setType(findCommonMimeType(mimeTypes)); |