From 2dd83ce992eaaf3d44c32bc7468b47e3db014027 Mon Sep 17 00:00:00 2001 From: The Android Open Source Project Date: Tue, 3 Mar 2009 19:32:40 -0800 Subject: auto import from //depot/cupcake/@135843 --- src/com/android/providers/downloads/Constants.java | 151 ++++ .../providers/downloads/DownloadFileInfo.java | 34 + .../android/providers/downloads/DownloadInfo.java | 212 +++++ .../providers/downloads/DownloadNotification.java | 300 +++++++ .../providers/downloads/DownloadProvider.java | 731 +++++++++++++++++ .../providers/downloads/DownloadReceiver.java | 159 ++++ .../providers/downloads/DownloadService.java | 879 +++++++++++++++++++++ .../providers/downloads/DownloadThread.java | 710 +++++++++++++++++ src/com/android/providers/downloads/Helpers.java | 793 +++++++++++++++++++ 9 files changed, 3969 insertions(+) create mode 100644 src/com/android/providers/downloads/Constants.java create mode 100644 src/com/android/providers/downloads/DownloadFileInfo.java create mode 100644 src/com/android/providers/downloads/DownloadInfo.java create mode 100644 src/com/android/providers/downloads/DownloadNotification.java create mode 100644 src/com/android/providers/downloads/DownloadProvider.java create mode 100644 src/com/android/providers/downloads/DownloadReceiver.java create mode 100644 src/com/android/providers/downloads/DownloadService.java create mode 100644 src/com/android/providers/downloads/DownloadThread.java create mode 100644 src/com/android/providers/downloads/Helpers.java (limited to 'src/com/android/providers/downloads') diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java new file mode 100644 index 00000000..cffda04a --- /dev/null +++ b/src/com/android/providers/downloads/Constants.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2008 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.Config; +import android.util.Log; + +/** + * Contains the internal constants that are used in the download manager. + * As a general rule, modifying these constants should be done with care. + */ +public class Constants { + + /** Tag used for debugging/logging */ + public static final String TAG = "DownloadManager"; + + /** The column that used to be used for the HTTP method of the request */ + public static final String RETRY_AFTER___REDIRECT_COUNT = "method"; + + /** The column that used to be used for the magic OTA update filename */ + public static final String OTA_UPDATE = "otaupdate"; + + /** The column that used to be used to reject system filetypes */ + public static final String NO_SYSTEM_FILES = "no_system"; + + /** The column that is used for the downloads's ETag */ + public static final String ETAG = "etag"; + + /** The column that is used for the initiating app's UID */ + public static final String UID = "uid"; + + /** 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"; + + /** the intent that gets sent when clicking a successful download */ + public static final String ACTION_OPEN = "android.intent.action.DOWNLOAD_OPEN"; + + /** the intent that gets sent when clicking an incomplete/failed download */ + public static final String ACTION_LIST = "android.intent.action.DOWNLOAD_LIST"; + + /** the intent that gets sent when deleting the notification of a completed download */ + public static final String ACTION_HIDE = "android.intent.action.DOWNLOAD_HIDE"; + + /** The default base name for downloaded files if we can't get one at the HTTP level */ + public static final String DEFAULT_DL_FILENAME = "downloadfile"; + + /** The default extension for html files if we can't get one at the HTTP level */ + public static final String DEFAULT_DL_HTML_EXTENSION = ".html"; + + /** The default extension for text files if we can't get one at the HTTP level */ + public static final String DEFAULT_DL_TEXT_EXTENSION = ".txt"; + + /** The default extension for binary files if we can't get one at the HTTP level */ + public static final String DEFAULT_DL_BINARY_EXTENSION = ".bin"; + + /** + * When a number has to be appended to the filename, this string is used to separate the + * base filename from the sequence number + */ + public static final String FILENAME_SEQUENCE_SEPARATOR = "-"; + + /** Where we store downloaded files on the external storage */ + public static final String DEFAULT_DL_SUBDIR = "/download"; + + /** A magic filename that is allowed to exist within the system cache */ + public static final String KNOWN_SPURIOUS_FILENAME = "lost+found"; + + /** A magic filename that is allowed to exist within the system cache */ + public static final String RECOVERY_DIRECTORY = "recovery"; + + /** The default user agent used for downloads */ + public static final String DEFAULT_USER_AGENT = "AndroidDownloadManager"; + + /** The MIME type of special DRM files */ + public static final String MIMETYPE_DRM_MESSAGE = + android.drm.mobile1.DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING; + + /** The MIME type of APKs */ + public static final String MIMETYPE_APK = "application/vnd.android.package"; + + /** The buffer size used to stream the data */ + public static final int BUFFER_SIZE = 4096; + + /** The minimum amount of progress that has to be done before the progress bar gets updated */ + public static final int MIN_PROGRESS_STEP = 4096; + + /** The minimum amount of time that has to elapse before the progress bar gets updated, in ms */ + public static final long MIN_PROGRESS_TIME = 1500; + + /** The maximum number of rows in the database (FIFO) */ + public static final int MAX_DOWNLOADS = 1000; + + /** + * The number of times that the download manager will retry its network + * operations when no progress is happening before it gives up. + */ + public static final int MAX_RETRIES = 5; + + /** + * The minimum amount of time that the download manager accepts for + * a Retry-After response header with a parameter in delta-seconds. + */ + public static final int MIN_RETRY_AFTER = 30; // 30s + + /** + * The maximum amount of time that the download manager accepts for + * a Retry-After response header with a parameter in delta-seconds. + */ + public static final int MAX_RETRY_AFTER = 24 * 60 * 60; // 24h + + /** + * The maximum number of redirects. + */ + public static final int MAX_REDIRECTS = 5; // can't be more than 7. + + /** + * The time between a failure and the first retry after an IOException. + * Each subsequent retry grows exponentially, doubling each time. + * The time is in seconds. + */ + public static final int RETRY_FIRST_DELAY = 30; + + /** Enable verbose logging - use with "setprop log.tag.DownloadManager VERBOSE" */ + private static final boolean LOCAL_LOGV = true; + public static final boolean LOGV = Config.LOGV + || (Config.LOGD && LOCAL_LOGV && Log.isLoggable(TAG, Log.VERBOSE)); + + /** Enable super-verbose logging */ + private static final boolean LOCAL_LOGVV = false; + public static final boolean LOGVV = LOCAL_LOGVV && LOGV; +} diff --git a/src/com/android/providers/downloads/DownloadFileInfo.java b/src/com/android/providers/downloads/DownloadFileInfo.java new file mode 100644 index 00000000..29cbd940 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadFileInfo.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2008 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.FileOutputStream; + +/** + * Stores information about the file in which a download gets saved. + */ +public class DownloadFileInfo { + public DownloadFileInfo(String filename, FileOutputStream stream, int status) { + this.filename = filename; + this.stream = stream; + this.status = status; + } + + String filename; + FileOutputStream stream; + int status; +} diff --git a/src/com/android/providers/downloads/DownloadInfo.java b/src/com/android/providers/downloads/DownloadInfo.java new file mode 100644 index 00000000..e051f41a --- /dev/null +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2008 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.net.Uri; +import android.content.Context; +import android.content.Intent; +import android.provider.Downloads; + +/** + * Stores information about an individual download. + */ +public class DownloadInfo { + public int id; + public String uri; + public boolean noIntegrity; + public String hint; + public String filename; + public String mimetype; + public int destination; + public int visibility; + public int control; + public int status; + public int numFailed; + public int retryAfter; + public int redirectCount; + public long lastMod; + public String pckg; + public String clazz; + public String extras; + public String cookies; + public String userAgent; + public String referer; + public int totalBytes; + public int currentBytes; + public String etag; + public boolean mediaScanned; + + public volatile boolean hasActiveThread; + + public DownloadInfo(int id, String uri, boolean noIntegrity, + String hint, String filename, + String mimetype, int destination, int visibility, int control, + int status, int numFailed, int retryAfter, int redirectCount, long lastMod, + String pckg, String clazz, String extras, String cookies, + String userAgent, String referer, int totalBytes, int currentBytes, String etag, + boolean mediaScanned) { + this.id = id; + this.uri = uri; + this.noIntegrity = noIntegrity; + this.hint = hint; + this.filename = filename; + this.mimetype = mimetype; + this.destination = destination; + this.visibility = visibility; + this.control = control; + this.status = status; + this.numFailed = numFailed; + this.retryAfter = retryAfter; + this.redirectCount = redirectCount; + this.lastMod = lastMod; + this.pckg = pckg; + this.clazz = clazz; + this.extras = extras; + this.cookies = cookies; + this.userAgent = userAgent; + this.referer = referer; + this.totalBytes = totalBytes; + this.currentBytes = currentBytes; + this.etag = etag; + this.mediaScanned = mediaScanned; + } + + public void sendIntentIfRequested(Uri contentUri, Context context) { + if (pckg != null && clazz != null) { + Intent intent = new Intent(Downloads.DOWNLOAD_COMPLETED_ACTION); + intent.setClassName(pckg, clazz); + if (extras != null) { + intent.putExtra(Downloads.NOTIFICATION_EXTRAS, extras); + } + // We only send the content: URI, for security reasons. Otherwise, malicious + // applications would have an easier time spoofing download results by + // sending spoofed intents. + intent.setData(contentUri); + context.sendBroadcast(intent); + } + } + + /** + * Returns the time when a download should be restarted. Must only + * be called when numFailed > 0. + */ + public long restartTime() { + if (retryAfter > 0) { + return lastMod + retryAfter; + } + return lastMod + + Constants.RETRY_FIRST_DELAY * + (1000 + Helpers.rnd.nextInt(1001)) * (1 << (numFailed - 1)); + } + + /** + * Returns whether this download (which the download manager hasn't seen yet) + * should be started. + */ + public boolean isReadyToStart(long now) { + if (control == Downloads.CONTROL_PAUSED) { + // the download is paused, so it's not going to start + return false; + } + if (status == 0) { + // status hasn't been initialized yet, this is a new download + return true; + } + if (status == Downloads.STATUS_PENDING) { + // download is explicit marked as ready to start + return true; + } + if (status == Downloads.STATUS_RUNNING) { + // download was interrupted (process killed, loss of power) while it was running, + // without a chance to update the database + return true; + } + if (status == Downloads.STATUS_RUNNING_PAUSED) { + if (numFailed == 0) { + // download is waiting for network connectivity to return before it can resume + return true; + } + if (restartTime() < now) { + // download was waiting for a delayed restart, and the delay has expired + return true; + } + } + return false; + } + + /** + * Returns whether this download (which the download manager has already seen + * and therefore potentially started) should be restarted. + * + * In a nutshell, this returns true if the download isn't already running + * but should be, and it can know whether the download is already running + * by checking the status. + */ + public boolean isReadyToRestart(long now) { + if (control == Downloads.CONTROL_PAUSED) { + // the download is paused, so it's not going to restart + return false; + } + if (status == 0) { + // download hadn't been initialized yet + return true; + } + if (status == Downloads.STATUS_PENDING) { + // download is explicit marked as ready to start + return true; + } + if (status == Downloads.STATUS_RUNNING_PAUSED) { + if (numFailed == 0) { + // download is waiting for network connectivity to return before it can resume + return true; + } + if (restartTime() < now) { + // download was waiting for a delayed restart, and the delay has expired + return true; + } + } + return false; + } + + /** + * Returns whether this download has a visible notification after + * completion. + */ + public boolean hasCompletionNotification() { + if (!Downloads.isStatusCompleted(status)) { + return false; + } + if (visibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) { + return true; + } + return false; + } + + /** + * Returns whether this download is allowed to use the network. + */ + public boolean canUseNetwork(boolean available, boolean roaming) { + if (!available) { + return false; + } + if (destination == Downloads.DESTINATION_CACHE_PARTITION_NOROAMING) { + return !roaming; + } else { + return true; + } + } +} diff --git a/src/com/android/providers/downloads/DownloadNotification.java b/src/com/android/providers/downloads/DownloadNotification.java new file mode 100644 index 00000000..ed17ab7a --- /dev/null +++ b/src/com/android/providers/downloads/DownloadNotification.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2008 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.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.Downloads; +import android.widget.RemoteViews; + +import java.util.HashMap; + +/** + * This class handles the updating of the Notification Manager for the + * cases where there is an ongoing download. Once the download is complete + * (be it successful or unsuccessful) it is no longer the responsibility + * of this component to show the download in the notification manager. + * + */ +class DownloadNotification { + + Context mContext; + public NotificationManager mNotificationMgr; + HashMap mNotifications; + + static final String LOGTAG = "DownloadNotification"; + static final String WHERE_RUNNING = + "(" + Downloads.STATUS + " >= '100') AND (" + + Downloads.STATUS + " <= '199') AND (" + + Downloads.VISIBILITY + " IS NULL OR " + + Downloads.VISIBILITY + " == '" + Downloads.VISIBILITY_VISIBLE + "' OR " + + Downloads.VISIBILITY + " == '" + Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + "')"; + static final String WHERE_COMPLETED = + Downloads.STATUS + " >= '200' AND " + + Downloads.VISIBILITY + " == '" + Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + "'"; + + + /** + * This inner class is used to collate downloads that are owned by + * the same application. This is so that only one notification line + * item is used for all downloads of a given application. + * + */ + static class NotificationItem { + int id; // This first db _id for the download for the app + int totalCurrent = 0; + int totalTotal = 0; + int titleCount = 0; + String packageName; // App package name + String description; + String[] titles = new String[2]; // download titles. + + /* + * Add a second download to this notification item. + */ + void addItem(String title, int currentBytes, int totalBytes) { + totalCurrent += currentBytes; + if (totalBytes <= 0 || totalTotal == -1) { + totalTotal = -1; + } else { + totalTotal += totalBytes; + } + if (titleCount < 2) { + titles[titleCount] = title; + } + titleCount++; + } + } + + + /** + * Constructor + * @param ctx The context to use to obtain access to the + * Notification Service + */ + DownloadNotification(Context ctx) { + mContext = ctx; + mNotificationMgr = (NotificationManager) mContext + .getSystemService(Context.NOTIFICATION_SERVICE); + mNotifications = new HashMap(); + } + + /* + * Update the notification ui. + */ + public void updateNotification() { + updateActiveNotification(); + updateCompletedNotification(); + } + + private void updateActiveNotification() { + // Active downloads + Cursor c = mContext.getContentResolver().query( + Downloads.CONTENT_URI, new String [] { + Downloads._ID, Downloads.TITLE, Downloads.DESCRIPTION, + Downloads.NOTIFICATION_PACKAGE, + Downloads.NOTIFICATION_CLASS, + Downloads.CURRENT_BYTES, Downloads.TOTAL_BYTES, + Downloads.STATUS, Downloads._DATA + }, + WHERE_RUNNING, null, Downloads._ID); + + if (c == null) { + return; + } + + // Columns match projection in query above + final int idColumn = 0; + final int titleColumn = 1; + final int descColumn = 2; + final int ownerColumn = 3; + final int classOwnerColumn = 4; + final int currentBytesColumn = 5; + final int totalBytesColumn = 6; + final int statusColumn = 7; + final int filenameColumnId = 8; + + // Collate the notifications + mNotifications.clear(); + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + String packageName = c.getString(ownerColumn); + int max = c.getInt(totalBytesColumn); + int progress = c.getInt(currentBytesColumn); + String title = c.getString(titleColumn); + if (title == null || title.length() == 0) { + title = mContext.getResources().getString( + R.string.download_unknown_title); + } + if (mNotifications.containsKey(packageName)) { + mNotifications.get(packageName).addItem(title, progress, max); + } else { + NotificationItem item = new NotificationItem(); + item.id = c.getInt(idColumn); + item.packageName = packageName; + item.description = c.getString(descColumn); + String className = c.getString(classOwnerColumn); + item.addItem(title, progress, max); + mNotifications.put(packageName, item); + } + + } + c.close(); + + // Add the notifications + for (NotificationItem item : mNotifications.values()) { + // Build the notification object + Notification n = new Notification(); + n.icon = android.R.drawable.stat_sys_download; + + n.flags |= Notification.FLAG_ONGOING_EVENT; + + // Build the RemoteView object + RemoteViews expandedView = new RemoteViews( + "com.android.providers.downloads", + R.layout.status_bar_ongoing_event_progress_bar); + StringBuilder title = new StringBuilder(item.titles[0]); + if (item.titleCount > 1) { + title.append(mContext.getString(R.string.notification_filename_separator)); + title.append(item.titles[1]); + n.number = item.titleCount; + if (item.titleCount > 2) { + title.append(mContext.getString(R.string.notification_filename_extras, + new Object[] { Integer.valueOf(item.titleCount - 2) })); + } + } else { + expandedView.setTextViewText(R.id.description, + item.description); + } + expandedView.setTextViewText(R.id.title, title); + expandedView.setProgressBar(R.id.progress_bar, + item.totalTotal, + item.totalCurrent, + item.totalTotal == -1); + expandedView.setTextViewText(R.id.progress_text, + getDownloadingText(item.totalTotal, item.totalCurrent)); + expandedView.setImageViewResource(R.id.appIcon, + android.R.drawable.stat_sys_download); + n.contentView = expandedView; + + Intent intent = new Intent(Constants.ACTION_LIST); + intent.setClassName("com.android.providers.downloads", + DownloadReceiver.class.getName()); + intent.setData(Uri.parse(Downloads.CONTENT_URI + "/" + item.id)); + intent.putExtra("multiple", item.titleCount > 1); + + n.contentIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0); + + mNotificationMgr.notify(item.id, n); + + } + } + + private void updateCompletedNotification() { + // Completed downloads + Cursor c = mContext.getContentResolver().query( + Downloads.CONTENT_URI, new String [] { + Downloads._ID, Downloads.TITLE, Downloads.DESCRIPTION, + Downloads.NOTIFICATION_PACKAGE, + Downloads.NOTIFICATION_CLASS, + Downloads.CURRENT_BYTES, Downloads.TOTAL_BYTES, + Downloads.STATUS, Downloads._DATA, + Downloads.LAST_MODIFICATION, Downloads.DESTINATION + }, + WHERE_COMPLETED, null, Downloads._ID); + + if (c == null) { + return; + } + + // Columns match projection in query above + final int idColumn = 0; + final int titleColumn = 1; + final int descColumn = 2; + final int ownerColumn = 3; + final int classOwnerColumn = 4; + final int currentBytesColumn = 5; + final int totalBytesColumn = 6; + final int statusColumn = 7; + final int filenameColumnId = 8; + final int lastModColumnId = 9; + final int destinationColumnId = 10; + + for (c.moveToFirst(); !c.isAfterLast(); c.moveToNext()) { + // Add the notifications + Notification n = new Notification(); + n.icon = android.R.drawable.stat_sys_download_done; + + String title = c.getString(titleColumn); + if (title == null || title.length() == 0) { + title = mContext.getResources().getString( + R.string.download_unknown_title); + } + Uri contentUri = Uri.parse(Downloads.CONTENT_URI + "/" + c.getInt(idColumn)); + String caption; + Intent intent; + if (Downloads.isStatusError(c.getInt(statusColumn))) { + caption = mContext.getResources() + .getString(R.string.notification_download_failed); + intent = new Intent(Constants.ACTION_LIST); + } else { + caption = mContext.getResources() + .getString(R.string.notification_download_complete); + if (c.getInt(destinationColumnId) == Downloads.DESTINATION_EXTERNAL) { + intent = new Intent(Constants.ACTION_OPEN); + } else { + intent = new Intent(Constants.ACTION_LIST); + } + } + intent.setClassName("com.android.providers.downloads", + DownloadReceiver.class.getName()); + intent.setData(contentUri); + n.setLatestEventInfo(mContext, title, caption, + PendingIntent.getBroadcast(mContext, 0, intent, 0)); + + intent = new Intent(Constants.ACTION_HIDE); + intent.setClassName("com.android.providers.downloads", + DownloadReceiver.class.getName()); + intent.setData(contentUri); + n.deleteIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0); + + n.when = c.getLong(lastModColumnId); + + mNotificationMgr.notify(c.getInt(idColumn), n); + } + c.close(); + } + + /* + * Helper function to build the downloading text. + */ + private String getDownloadingText(long totalBytes, long currentBytes) { + if (totalBytes <= 0) { + return ""; + } + long progress = currentBytes * 100 / totalBytes; + StringBuilder sb = new StringBuilder(); + sb.append(progress); + sb.append('%'); + return sb.toString(); + } + +} diff --git a/src/com/android/providers/downloads/DownloadProvider.java b/src/com/android/providers/downloads/DownloadProvider.java new file mode 100644 index 00000000..f7cdd51e --- /dev/null +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -0,0 +1,731 @@ +/* + * Copyright (C) 2007 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.ContentProvider; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.UriMatcher; +import android.content.pm.PackageManager; +import android.database.CrossProcessCursor; +import android.database.Cursor; +import android.database.CursorWindow; +import android.database.CursorWrapper; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteQueryBuilder; +import android.database.SQLException; +import android.net.Uri; +import android.os.Binder; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.provider.Downloads; +import android.util.Config; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.HashSet; + + +/** + * Allows application to interact with the download manager. + */ +public final class DownloadProvider extends ContentProvider { + + /** Database filename */ + private static final String DB_NAME = "downloads.db"; + /** Current database version */ + private static final int DB_VERSION = 100; + /** Database version from which upgrading is a nop */ + private static final int DB_VERSION_NOP_UPGRADE_FROM = 31; + /** Database version to which upgrading is a nop */ + private static final int DB_VERSION_NOP_UPGRADE_TO = 100; + /** Name of table in the database */ + private static final String DB_TABLE = "downloads"; + + /** MIME type for the entire download list */ + private static final String DOWNLOAD_LIST_TYPE = "vnd.android.cursor.dir/download"; + /** MIME type for an individual download */ + private static final String DOWNLOAD_TYPE = "vnd.android.cursor.item/download"; + + /** URI matcher used to recognize URIs sent by applications */ + private static final UriMatcher sURIMatcher = new UriMatcher(UriMatcher.NO_MATCH); + /** URI matcher constant for the URI of the entire download list */ + private static final int DOWNLOADS = 1; + /** URI matcher constant for the URI of an individual download */ + private static final int DOWNLOADS_ID = 2; + static { + sURIMatcher.addURI("downloads", "download", DOWNLOADS); + sURIMatcher.addURI("downloads", "download/#", DOWNLOADS_ID); + } + + private static final String[] sAppReadableColumnsArray = new String[] { + Downloads._ID, + Downloads.APP_DATA, + Downloads._DATA, + Downloads.MIMETYPE, + Downloads.VISIBILITY, + Downloads.CONTROL, + Downloads.STATUS, + Downloads.LAST_MODIFICATION, + Downloads.NOTIFICATION_PACKAGE, + Downloads.NOTIFICATION_CLASS, + Downloads.TOTAL_BYTES, + Downloads.CURRENT_BYTES, + Downloads.TITLE, + Downloads.DESCRIPTION + }; + + private static HashSet sAppReadableColumnsSet; + static { + sAppReadableColumnsSet = new HashSet(); + for (int i = 0; i < sAppReadableColumnsArray.length; ++i) { + sAppReadableColumnsSet.add(sAppReadableColumnsArray[i]); + } + } + + /** The database that lies underneath this content provider */ + private SQLiteOpenHelper mOpenHelper = null; + + /** + * Creates and updated database on demand when opening it. + * Helper class to create database the first time the provider is + * initialized and upgrade it when a new version of the provider needs + * an updated version of the database. + */ + private final class DatabaseHelper extends SQLiteOpenHelper { + + public DatabaseHelper(final Context context) { + super(context, DB_NAME, null, DB_VERSION); + } + + /** + * Creates database the first time we try to open it. + */ + @Override + public void onCreate(final SQLiteDatabase db) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "populating new database"); + } + createTable(db); + } + + /* (not a javadoc comment) + * Checks data integrity when opening the database. + */ + /* + * @Override + * public void onOpen(final SQLiteDatabase db) { + * super.onOpen(db); + * } + */ + + /** + * Updates the database format when a content provider is used + * with a database that was created with a different format. + */ + // Note: technically, this could also be a downgrade, so if we want + // to gracefully handle upgrades we should be careful about + // what to do on downgrades. + @Override + public void onUpgrade(final SQLiteDatabase db, int oldV, final int newV) { + if (oldV == DB_VERSION_NOP_UPGRADE_FROM) { + if (newV == DB_VERSION_NOP_UPGRADE_TO) { // that's a no-op upgrade. + return; + } + // NOP_FROM and NOP_TO are identical, just in different codelines. Upgrading + // from NOP_FROM is the same as upgrading from NOP_TO. + oldV = DB_VERSION_NOP_UPGRADE_TO; + } + Log.i(Constants.TAG, "Upgrading downloads database from version " + oldV + " to " + newV + + ", which will destroy all old data"); + dropTable(db); + createTable(db); + } + } + + /** + * Initializes the content provider when it is created. + */ + @Override + public boolean onCreate() { + mOpenHelper = new DatabaseHelper(getContext()); + return true; + } + + /** + * Returns the content-provider-style MIME types of the various + * types accessible through this content provider. + */ + @Override + public String getType(final Uri uri) { + int match = sURIMatcher.match(uri); + switch (match) { + case DOWNLOADS: { + return DOWNLOAD_LIST_TYPE; + } + case DOWNLOADS_ID: { + return DOWNLOAD_TYPE; + } + default: { + if (Constants.LOGV) { + Log.v(Constants.TAG, "calling getType on an unknown URI: " + uri); + } + throw new IllegalArgumentException("Unknown URI: " + uri); + } + } + } + + /** + * Creates the table that'll hold the download information. + */ + private void createTable(SQLiteDatabase db) { + try { + db.execSQL("CREATE TABLE " + DB_TABLE + "(" + + Downloads._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Downloads.URI + " TEXT, " + + Constants.RETRY_AFTER___REDIRECT_COUNT + " INTEGER, " + + Downloads.APP_DATA + " TEXT, " + + Downloads.NO_INTEGRITY + " BOOLEAN, " + + Downloads.FILENAME_HINT + " TEXT, " + + Constants.OTA_UPDATE + " BOOLEAN, " + + Downloads._DATA + " TEXT, " + + Downloads.MIMETYPE + " TEXT, " + + Downloads.DESTINATION + " INTEGER, " + + Constants.NO_SYSTEM_FILES + " BOOLEAN, " + + Downloads.VISIBILITY + " INTEGER, " + + Downloads.CONTROL + " INTEGER, " + + Downloads.STATUS + " INTEGER, " + + Constants.FAILED_CONNECTIONS + " INTEGER, " + + Downloads.LAST_MODIFICATION + " BIGINT, " + + Downloads.NOTIFICATION_PACKAGE + " TEXT, " + + Downloads.NOTIFICATION_CLASS + " TEXT, " + + Downloads.NOTIFICATION_EXTRAS + " TEXT, " + + Downloads.COOKIE_DATA + " TEXT, " + + Downloads.USER_AGENT + " TEXT, " + + Downloads.REFERER + " TEXT, " + + Downloads.TOTAL_BYTES + " INTEGER, " + + Downloads.CURRENT_BYTES + " INTEGER, " + + Constants.ETAG + " TEXT, " + + Constants.UID + " INTEGER, " + + Downloads.OTHER_UID + " INTEGER, " + + Downloads.TITLE + " TEXT, " + + Downloads.DESCRIPTION + " TEXT, " + + Constants.MEDIA_SCANNED + " BOOLEAN);"); + } catch (SQLException ex) { + Log.e(Constants.TAG, "couldn't create table in downloads database"); + throw ex; + } + } + + /** + * Deletes the table that holds the download information. + */ + private void dropTable(SQLiteDatabase db) { + try { + db.execSQL("DROP TABLE IF EXISTS " + DB_TABLE); + } catch (SQLException ex) { + Log.e(Constants.TAG, "couldn't drop table in downloads database"); + throw ex; + } + } + + /** + * Inserts a row in the database + */ + @Override + public Uri insert(final Uri uri, final ContentValues values) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + + if (sURIMatcher.match(uri) != DOWNLOADS) { + if (Config.LOGD) { + Log.d(Constants.TAG, "calling insert on an unknown/invalid URI: " + uri); + } + throw new IllegalArgumentException("Unknown/Invalid URI " + uri); + } + + ContentValues filteredValues = new ContentValues(); + + copyString(Downloads.URI, values, filteredValues); + copyString(Downloads.APP_DATA, values, filteredValues); + copyBoolean(Downloads.NO_INTEGRITY, values, filteredValues); + copyString(Downloads.FILENAME_HINT, values, filteredValues); + copyString(Downloads.MIMETYPE, values, filteredValues); + Integer dest = values.getAsInteger(Downloads.DESTINATION); + if (dest != null) { + if (getContext().checkCallingPermission(Downloads.PERMISSION_ACCESS_ADVANCED) + != PackageManager.PERMISSION_GRANTED + && dest != Downloads.DESTINATION_EXTERNAL + && dest != Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE) { + throw new SecurityException("unauthorized destination code"); + } + filteredValues.put(Downloads.DESTINATION, dest); + } + Integer vis = values.getAsInteger(Downloads.VISIBILITY); + if (vis == null) { + if (dest == Downloads.DESTINATION_EXTERNAL) { + filteredValues.put(Downloads.VISIBILITY, + Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); + } else { + filteredValues.put(Downloads.VISIBILITY, Downloads.VISIBILITY_HIDDEN); + } + } else { + filteredValues.put(Downloads.VISIBILITY, vis); + } + copyInteger(Downloads.CONTROL, values, filteredValues); + filteredValues.put(Downloads.STATUS, Downloads.STATUS_PENDING); + filteredValues.put(Downloads.LAST_MODIFICATION, System.currentTimeMillis()); + String pckg = values.getAsString(Downloads.NOTIFICATION_PACKAGE); + String clazz = values.getAsString(Downloads.NOTIFICATION_CLASS); + if (pckg != null && clazz != null) { + int uid = Binder.getCallingUid(); + try { + if (uid == 0 || + getContext().getPackageManager().getApplicationInfo(pckg, 0).uid == uid) { + filteredValues.put(Downloads.NOTIFICATION_PACKAGE, pckg); + filteredValues.put(Downloads.NOTIFICATION_CLASS, clazz); + } + } catch (PackageManager.NameNotFoundException ex) { + /* ignored for now */ + } + } + copyString(Downloads.NOTIFICATION_EXTRAS, values, filteredValues); + copyString(Downloads.COOKIE_DATA, values, filteredValues); + copyString(Downloads.USER_AGENT, values, filteredValues); + copyString(Downloads.REFERER, values, filteredValues); + if (getContext().checkCallingPermission(Downloads.PERMISSION_ACCESS_ADVANCED) + == PackageManager.PERMISSION_GRANTED) { + copyInteger(Downloads.OTHER_UID, values, filteredValues); + } + filteredValues.put(Constants.UID, Binder.getCallingUid()); + if (Binder.getCallingUid() == 0) { + copyInteger(Constants.UID, values, filteredValues); + } + copyString(Downloads.TITLE, values, filteredValues); + copyString(Downloads.DESCRIPTION, values, filteredValues); + + if (Constants.LOGVV) { + Log.v(Constants.TAG, "initiating download with UID " + + filteredValues.getAsInteger(Constants.UID)); + if (filteredValues.containsKey(Downloads.OTHER_UID)) { + Log.v(Constants.TAG, "other UID " + + filteredValues.getAsInteger(Downloads.OTHER_UID)); + } + } + + Context context = getContext(); + context.startService(new Intent(context, DownloadService.class)); + + long rowID = db.insert(DB_TABLE, null, filteredValues); + + Uri ret = null; + + if (rowID != -1) { + context.startService(new Intent(context, DownloadService.class)); + ret = Uri.parse(Downloads.CONTENT_URI + "/" + rowID); + context.getContentResolver().notifyChange(uri, null); + } else { + if (Config.LOGD) { + Log.d(Constants.TAG, "couldn't insert into downloads database"); + } + } + + return ret; + } + + /** + * Starts a database query + */ + @Override + public Cursor query(final Uri uri, String[] projection, + final String selection, final String[] selectionArgs, + final String sort) { + + Helpers.validateSelection(selection, sAppReadableColumnsSet); + + SQLiteDatabase db = mOpenHelper.getReadableDatabase(); + + SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + + int match = sURIMatcher.match(uri); + boolean emptyWhere = true; + switch (match) { + case DOWNLOADS: { + qb.setTables(DB_TABLE); + break; + } + case DOWNLOADS_ID: { + qb.setTables(DB_TABLE); + qb.appendWhere(Downloads._ID + "="); + qb.appendWhere(uri.getPathSegments().get(1)); + emptyWhere = false; + break; + } + default: { + if (Constants.LOGV) { + Log.v(Constants.TAG, "querying unknown URI: " + uri); + } + throw new IllegalArgumentException("Unknown URI: " + uri); + } + } + + if (Binder.getCallingPid() != Process.myPid() && Binder.getCallingUid() != 0) { + if (!emptyWhere) { + qb.appendWhere(" AND "); + } + qb.appendWhere("( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"); + emptyWhere = false; + + if (projection == null) { + projection = sAppReadableColumnsArray; + } else { + for (int i = 0; i < projection.length; ++i) { + if (!sAppReadableColumnsSet.contains(projection[i])) { + throw new IllegalArgumentException( + "column " + projection[i] + " is not allowed in queries"); + } + } + } + } + + if (Constants.LOGVV) { + java.lang.StringBuilder sb = new java.lang.StringBuilder(); + sb.append("starting query, database is "); + if (db != null) { + sb.append("not "); + } + sb.append("null; "); + if (projection == null) { + sb.append("projection is null; "); + } else if (projection.length == 0) { + sb.append("projection is empty; "); + } else { + for (int i = 0; i < projection.length; ++i) { + sb.append("projection["); + sb.append(i); + sb.append("] is "); + sb.append(projection[i]); + sb.append("; "); + } + } + sb.append("selection is "); + sb.append(selection); + sb.append("; "); + if (selectionArgs == null) { + sb.append("selectionArgs is null; "); + } else if (selectionArgs.length == 0) { + sb.append("selectionArgs is empty; "); + } else { + for (int i = 0; i < selectionArgs.length; ++i) { + sb.append("selectionArgs["); + sb.append(i); + sb.append("] is "); + sb.append(selectionArgs[i]); + sb.append("; "); + } + } + sb.append("sort is "); + sb.append(sort); + sb.append("."); + Log.v(Constants.TAG, sb.toString()); + } + + Cursor ret = qb.query(db, projection, selection, selectionArgs, + null, null, sort); + + if (ret != null) { + ret = new ReadOnlyCursorWrapper(ret); + } + + if (ret != null) { + ret.setNotificationUri(getContext().getContentResolver(), uri); + if (Constants.LOGVV) { + Log.v(Constants.TAG, + "created cursor " + ret + " on behalf of " + Binder.getCallingPid()); + } + } else { + if (Constants.LOGV) { + Log.v(Constants.TAG, "query failed in downloads database"); + } + } + + return ret; + } + + /** + * Updates a row in the database + */ + @Override + public int update(final Uri uri, final ContentValues values, + final String where, final String[] whereArgs) { + + Helpers.validateSelection(where, sAppReadableColumnsSet); + + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + + int count; + long rowId = 0; + boolean startService = false; + + ContentValues filteredValues; + if (Binder.getCallingPid() != Process.myPid()) { + filteredValues = new ContentValues(); + copyString(Downloads.APP_DATA, values, filteredValues); + copyInteger(Downloads.VISIBILITY, values, filteredValues); + Integer i = values.getAsInteger(Downloads.CONTROL); + if (i != null) { + filteredValues.put(Downloads.CONTROL, i); + startService = true; + } + copyInteger(Downloads.CONTROL, values, filteredValues); + copyString(Downloads.TITLE, values, filteredValues); + copyString(Downloads.DESCRIPTION, values, filteredValues); + } else { + filteredValues = values; + } + int match = sURIMatcher.match(uri); + switch (match) { + case DOWNLOADS: + case DOWNLOADS_ID: { + String myWhere; + if (where != null) { + if (match == DOWNLOADS) { + myWhere = "( " + where + " )"; + } else { + myWhere = "( " + where + " ) AND "; + } + } else { + myWhere = ""; + } + if (match == DOWNLOADS_ID) { + String segment = uri.getPathSegments().get(1); + rowId = Long.parseLong(segment); + myWhere += " ( " + Downloads._ID + " = " + rowId + " ) "; + } + if (Binder.getCallingPid() != Process.myPid() && Binder.getCallingUid() != 0) { + myWhere += " AND ( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"; + } + if (filteredValues.size() > 0) { + count = db.update(DB_TABLE, filteredValues, myWhere, whereArgs); + } else { + count = 0; + } + break; + } + default: { + if (Config.LOGD) { + Log.d(Constants.TAG, "updating unknown/invalid URI: " + uri); + } + throw new UnsupportedOperationException("Cannot update URI: " + uri); + } + } + getContext().getContentResolver().notifyChange(uri, null); + if (startService) { + Context context = getContext(); + context.startService(new Intent(context, DownloadService.class)); + } + return count; + } + + /** + * Deletes a row in the database + */ + @Override + public int delete(final Uri uri, final String where, + final String[] whereArgs) { + + Helpers.validateSelection(where, sAppReadableColumnsSet); + + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + int count; + int match = sURIMatcher.match(uri); + switch (match) { + case DOWNLOADS: + case DOWNLOADS_ID: { + String myWhere; + if (where != null) { + if (match == DOWNLOADS) { + myWhere = "( " + where + " )"; + } else { + myWhere = "( " + where + " ) AND "; + } + } else { + myWhere = ""; + } + if (match == DOWNLOADS_ID) { + String segment = uri.getPathSegments().get(1); + long rowId = Long.parseLong(segment); + myWhere += " ( " + Downloads._ID + " = " + rowId + " ) "; + } + if (Binder.getCallingPid() != Process.myPid() && Binder.getCallingUid() != 0) { + myWhere += " AND ( " + Constants.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"; + } + count = db.delete(DB_TABLE, myWhere, whereArgs); + break; + } + default: { + if (Config.LOGD) { + Log.d(Constants.TAG, "deleting unknown/invalid URI: " + uri); + } + throw new UnsupportedOperationException("Cannot delete URI: " + uri); + } + } + getContext().getContentResolver().notifyChange(uri, null); + return count; + } + + /** + * Remotely opens a file + */ + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) + throws FileNotFoundException { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "openFile uri: " + uri + ", mode: " + mode + + ", uid: " + Binder.getCallingUid()); + Cursor cursor = query(Downloads.CONTENT_URI, new String[] { "_id" }, null, null, "_id"); + if (cursor == null) { + Log.v(Constants.TAG, "null cursor in openFile"); + } else { + if (!cursor.moveToFirst()) { + Log.v(Constants.TAG, "empty cursor in openFile"); + } else { + do { + Log.v(Constants.TAG, "row " + cursor.getInt(0) + " available"); + } while(cursor.moveToNext()); + } + cursor.close(); + } + cursor = query(uri, new String[] { "_data" }, null, null, null); + if (cursor == null) { + Log.v(Constants.TAG, "null cursor in openFile"); + } else { + if (!cursor.moveToFirst()) { + Log.v(Constants.TAG, "empty cursor in openFile"); + } else { + String filename = cursor.getString(0); + Log.v(Constants.TAG, "filename in openFile: " + filename); + if (new java.io.File(filename).isFile()) { + Log.v(Constants.TAG, "file exists in openFile"); + } + } + cursor.close(); + } + } + + // This logic is mostly copied form openFileHelper. If openFileHelper eventually + // gets split into small bits (to extract the filename and the modebits), + // this code could use the separate bits and be deeply simplified. + Cursor c = query(uri, new String[]{"_data"}, null, null, null); + int count = (c != null) ? c.getCount() : 0; + if (count != 1) { + // If there is not exactly one result, throw an appropriate exception. + if (c != null) { + c.close(); + } + if (count == 0) { + throw new FileNotFoundException("No entry for " + uri); + } + throw new FileNotFoundException("Multiple items at " + uri); + } + + c.moveToFirst(); + String path = c.getString(0); + c.close(); + if (path == null) { + throw new FileNotFoundException("No filename found."); + } + if (!Helpers.isFilenameValid(path)) { + throw new FileNotFoundException("Invalid filename."); + } + + if (!"r".equals(mode)) { + throw new FileNotFoundException("Bad mode for " + uri + ": " + mode); + } + ParcelFileDescriptor ret = ParcelFileDescriptor.open(new File(path), + ParcelFileDescriptor.MODE_READ_ONLY); + + if (ret == null) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "couldn't open file"); + } + throw new FileNotFoundException("couldn't open file"); + } else { + ContentValues values = new ContentValues(); + values.put(Downloads.LAST_MODIFICATION, System.currentTimeMillis()); + update(uri, values, null, null); + } + return ret; + } + + private static final void copyInteger(String key, ContentValues from, ContentValues to) { + Integer i = from.getAsInteger(key); + if (i != null) { + to.put(key, i); + } + } + + private static final void copyBoolean(String key, ContentValues from, ContentValues to) { + Boolean b = from.getAsBoolean(key); + if (b != null) { + to.put(key, b); + } + } + + private static final void copyString(String key, ContentValues from, ContentValues to) { + String s = from.getAsString(key); + if (s != null) { + to.put(key, s); + } + } + + private class ReadOnlyCursorWrapper extends CursorWrapper implements CrossProcessCursor { + public ReadOnlyCursorWrapper(Cursor cursor) { + super(cursor); + mCursor = (CrossProcessCursor) cursor; + } + + public boolean deleteRow() { + throw new SecurityException("Download manager cursors are read-only"); + } + + public boolean commitUpdates() { + throw new SecurityException("Download manager cursors are read-only"); + } + + public void fillWindow(int pos, CursorWindow window) { + mCursor.fillWindow(pos, window); + } + + public CursorWindow getWindow() { + return mCursor.getWindow(); + } + + public boolean onMove(int oldPosition, int newPosition) { + return mCursor.onMove(oldPosition, newPosition); + } + + private CrossProcessCursor mCursor; + } + +} diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java new file mode 100644 index 00000000..03a37186 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadReceiver.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2008 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.app.NotificationManager; +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.database.Cursor; +import android.provider.Downloads; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.util.Config; +import android.util.Log; + +import java.io.File; +import java.util.List; + +/** + * Receives system broadcasts (boot, network connectivity) + */ +public class DownloadReceiver extends BroadcastReceiver { + + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Receiver onBoot"); + } + context.startService(new Intent(context, DownloadService.class)); + } else if (intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Receiver onConnectivity"); + } + NetworkInfo info = (NetworkInfo) + intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO); + if (info != null && info.isConnected()) { + context.startService(new Intent(context, DownloadService.class)); + } + } else if (intent.getAction().equals(Constants.ACTION_RETRY)) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Receiver retry"); + } + context.startService(new Intent(context, DownloadService.class)); + } else if (intent.getAction().equals(Constants.ACTION_OPEN) + || intent.getAction().equals(Constants.ACTION_LIST)) { + if (Constants.LOGVV) { + if (intent.getAction().equals(Constants.ACTION_OPEN)) { + Log.v(Constants.TAG, "Receiver open for " + intent.getData()); + } else { + Log.v(Constants.TAG, "Receiver list for " + intent.getData()); + } + } + Cursor cursor = context.getContentResolver().query( + intent.getData(), null, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int status = cursor.getInt(statusColumn); + int visibilityColumn = cursor.getColumnIndexOrThrow(Downloads.VISIBILITY); + int visibility = cursor.getInt(visibilityColumn); + if (Downloads.isStatusCompleted(status) + && visibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) { + ContentValues values = new ContentValues(); + values.put(Downloads.VISIBILITY, Downloads.VISIBILITY_VISIBLE); + context.getContentResolver().update(intent.getData(), values, null, null); + } + + if (intent.getAction().equals(Constants.ACTION_OPEN)) { + int filenameColumn = cursor.getColumnIndexOrThrow(Downloads._DATA); + int mimetypeColumn = cursor.getColumnIndexOrThrow(Downloads.MIMETYPE); + String filename = cursor.getString(filenameColumn); + String mimetype = cursor.getString(mimetypeColumn); + Uri path = Uri.parse(filename); + // If there is no scheme, then it must be a file + if (path.getScheme() == null) { + path = Uri.fromFile(new File(filename)); + } + Intent activityIntent = new Intent(Intent.ACTION_VIEW); + activityIntent.setDataAndType(path, mimetype); + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(activityIntent); + } catch (ActivityNotFoundException ex) { + if (Config.LOGD) { + Log.d(Constants.TAG, "no activity for " + mimetype, ex); + } + // nothing anyone can do about this, but we're in a clean state, + // swallow the exception entirely + } + } else { + int packageColumn = + cursor.getColumnIndexOrThrow(Downloads.NOTIFICATION_PACKAGE); + int classColumn = + cursor.getColumnIndexOrThrow(Downloads.NOTIFICATION_CLASS); + String pckg = cursor.getString(packageColumn); + String clazz = cursor.getString(classColumn); + if (pckg != null && clazz != null) { + Intent appIntent = new Intent(Downloads.NOTIFICATION_CLICKED_ACTION); + appIntent.setClassName(pckg, clazz); + if (intent.getBooleanExtra("multiple", true)) { + appIntent.setData(Downloads.CONTENT_URI); + } else { + appIntent.setData(intent.getData()); + } + context.sendBroadcast(appIntent); + } + } + } + cursor.close(); + } + NotificationManager notMgr = (NotificationManager) context + .getSystemService(Context.NOTIFICATION_SERVICE); + if (notMgr != null) { + notMgr.cancel((int) ContentUris.parseId(intent.getData())); + } + } else if (intent.getAction().equals(Constants.ACTION_HIDE)) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Receiver hide for " + intent.getData()); + } + Cursor cursor = context.getContentResolver().query( + intent.getData(), null, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int status = cursor.getInt(statusColumn); + int visibilityColumn = cursor.getColumnIndexOrThrow(Downloads.VISIBILITY); + int visibility = cursor.getInt(visibilityColumn); + if (Downloads.isStatusCompleted(status) + && visibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) { + ContentValues values = new ContentValues(); + values.put(Downloads.VISIBILITY, Downloads.VISIBILITY_VISIBLE); + context.getContentResolver().update(intent.getData(), values, null, null); + } + } + cursor.close(); + } + } + } +} diff --git a/src/com/android/providers/downloads/DownloadService.java b/src/com/android/providers/downloads/DownloadService.java new file mode 100644 index 00000000..0600cfb6 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadService.java @@ -0,0 +1,879 @@ +/* + * Copyright (C) 2008 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 com.google.android.collect.Lists; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.ServiceConnection; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.CharArrayBuffer; +import android.drm.mobile1.DrmRawContent; +import android.media.IMediaScannerService; +import android.net.Uri; +import android.os.RemoteException; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Process; +import android.provider.Downloads; +import android.util.Config; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + + +/** + * Performs the background downloads requested by applications that use the Downloads provider. + */ +public class DownloadService extends Service { + + /* ------------ Constants ------------ */ + + /* ------------ Members ------------ */ + + /** Observer to get notified when the content observer's data changes */ + private DownloadManagerContentObserver mObserver; + + /** Class to handle Notification Manager updates */ + private DownloadNotification mNotifier; + + /** + * The Service's view of the list of downloads. This is kept independently + * from the content provider, and the Service only initiates downloads + * based on this data, so that it can deal with situation where the data + * in the content provider changes or disappears. + */ + private ArrayList mDownloads; + + /** + * The thread that updates the internal download list from the content + * provider. + */ + private UpdateThread updateThread; + + /** + * Whether the internal download list should be updated from the content + * provider. + */ + private boolean pendingUpdate; + + /** + * The ServiceConnection object that tells us when we're connected to and disconnected from + * the Media Scanner + */ + private MediaScannerConnection mMediaScannerConnection; + + private boolean mMediaScannerConnecting; + + /** + * The IPC interface to the Media Scanner + */ + private IMediaScannerService mMediaScannerService; + + /** + * Array used when extracting strings from content provider + */ + private CharArrayBuffer oldChars; + + /** + * Array used when extracting strings from content provider + */ + private CharArrayBuffer newChars; + + /* ------------ Inner Classes ------------ */ + + /** + * 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. + */ + 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"); + } + mMediaScannerConnecting = false; + synchronized (DownloadService.this) { + mMediaScannerService = IMediaScannerService.Stub.asInterface(service); + if (mMediaScannerService != null) { + updateFromProvider(); + } + } + } + + public void disconnectMediaScanner() { + synchronized (DownloadService.this) { + if (mMediaScannerService != null) { + mMediaScannerService = null; + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Disconnecting from Media Scanner"); + } + try { + unbindService(this); + } catch (IllegalArgumentException ex) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "unbindService threw up: " + ex); + } + } + } + } + } + + public void onServiceDisconnected(ComponentName className) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Disconnected from Media Scanner"); + } + synchronized (DownloadService.this) { + mMediaScannerService = null; + } + } + } + + /* ------------ Methods ------------ */ + + /** + * Returns an IBinder instance when someone wants to connect to this + * service. Binding to this service is not allowed. + * + * @throws UnsupportedOperationException + */ + public IBinder onBind(Intent i) { + throw new UnsupportedOperationException("Cannot bind to Download Manager Service"); + } + + /** + * Initializes the service when it is first created + */ + public void onCreate() { + super.onCreate(); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Service onCreate"); + } + + mDownloads = Lists.newArrayList(); + + mObserver = new DownloadManagerContentObserver(); + getContentResolver().registerContentObserver(Downloads.CONTENT_URI, + true, mObserver); + + mMediaScannerService = null; + mMediaScannerConnecting = false; + mMediaScannerConnection = new MediaScannerConnection(); + + mNotifier = new DownloadNotification(this); + mNotifier.mNotificationMgr.cancelAll(); + mNotifier.updateNotification(); + + trimDatabase(); + removeSpuriousFiles(); + updateFromProvider(); + } + + /** + * Responds to a call to startService + */ + public void onStart(Intent intent, int startId) { + super.onStart(intent, startId); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Service onStart"); + } + + updateFromProvider(); + } + + /** + * Cleans up when the service is destroyed + */ + public void onDestroy() { + getContentResolver().unregisterContentObserver(mObserver); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Service onDestroy"); + } + super.onDestroy(); + } + + /** + * Parses data from the content provider into private array + */ + private void updateFromProvider() { + synchronized (this) { + pendingUpdate = true; + if (updateThread == null) { + updateThread = new UpdateThread(); + updateThread.start(); + } + } + } + + private class UpdateThread extends Thread { + public UpdateThread() { + super("Download Service"); + } + + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + boolean keepService = false; + // for each update from the database, remember which download is + // supposed to get restarted soonest in the future + long wakeUp = Long.MAX_VALUE; + for (;;) { + synchronized (DownloadService.this) { + if (updateThread != this) { + throw new IllegalStateException( + "multiple UpdateThreads in DownloadService"); + } + if (!pendingUpdate) { + updateThread = null; + if (!keepService) { + stopSelf(); + } + if (wakeUp != Long.MAX_VALUE) { + AlarmManager alarms = + (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (alarms == null) { + Log.e(Constants.TAG, "couldn't get alarm manager"); + } else { + if (Constants.LOGV) { + Log.v(Constants.TAG, "scheduling retry in " + wakeUp + "ms"); + } + Intent intent = new Intent(Constants.ACTION_RETRY); + intent.setClassName("com.android.providers.downloads", + DownloadReceiver.class.getName()); + alarms.set( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + wakeUp, + PendingIntent.getBroadcast(DownloadService.this, 0, intent, + PendingIntent.FLAG_ONE_SHOT)); + } + } + oldChars = null; + newChars = null; + return; + } + pendingUpdate = false; + } + boolean networkAvailable = Helpers.isNetworkAvailable(DownloadService.this); + boolean networkRoaming = Helpers.isNetworkRoaming(DownloadService.this); + long now = System.currentTimeMillis(); + + Cursor cursor = getContentResolver().query(Downloads.CONTENT_URI, + null, null, null, Downloads._ID); + + if (cursor == null) { + return; + } + + cursor.moveToFirst(); + + int arrayPos = 0; + + boolean mustScan = false; + keepService = false; + wakeUp = Long.MAX_VALUE; + + boolean isAfterLast = cursor.isAfterLast(); + + int idColumn = cursor.getColumnIndexOrThrow(Downloads._ID); + + /* + * Walk the cursor and the local array to keep them in sync. The key + * to the algorithm is that the ids are unique and sorted both in + * the cursor and in the array, so that they can be processed in + * order in both sources at the same time: at each step, both + * sources point to the lowest id that hasn't been processed from + * that source, and the algorithm processes the lowest id from + * those two possibilities. + * At each step: + * -If the array contains an entry that's not in the cursor, remove the + * entry, move to next entry in the array. + * -If the array contains an entry that's in the cursor, nothing to do, + * move to next cursor row and next array entry. + * -If the cursor contains an entry that's not in the array, insert + * a new entry in the array, move to next cursor row and next + * array entry. + */ + while (!isAfterLast || arrayPos < mDownloads.size()) { + if (isAfterLast) { + // We're beyond the end of the cursor but there's still some + // stuff in the local array, which can only be junk + if (Constants.LOGVV) { + int arrayId = ((DownloadInfo) mDownloads.get(arrayPos)).id; + Log.v(Constants.TAG, "Array update: trimming " + + arrayId + " @ " + arrayPos); + } + if (shouldScanFile(arrayPos) && mediaScannerConnected()) { + scanFile(null, arrayPos); + } + deleteDownload(arrayPos); // this advances in the array + } else { + int id = cursor.getInt(idColumn); + + if (arrayPos == mDownloads.size()) { + insertDownload(cursor, arrayPos, networkAvailable, networkRoaming, now); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Array update: inserting " + + id + " @ " + arrayPos); + } + if (shouldScanFile(arrayPos) + && (!mediaScannerConnected() || !scanFile(cursor, arrayPos))) { + mustScan = true; + keepService = true; + } + if (visibleNotification(arrayPos)) { + keepService = true; + } + long next = nextAction(arrayPos, now); + if (next == 0) { + keepService = true; + } else if (next > 0 && next < wakeUp) { + wakeUp = next; + } + ++arrayPos; + cursor.moveToNext(); + isAfterLast = cursor.isAfterLast(); + } else { + int arrayId = mDownloads.get(arrayPos).id; + + if (arrayId < id) { + // The array entry isn't in the cursor + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Array update: removing " + arrayId + + " @ " + arrayPos); + } + if (shouldScanFile(arrayPos) && mediaScannerConnected()) { + scanFile(null, arrayPos); + } + deleteDownload(arrayPos); // this advances in the array + } else if (arrayId == id) { + // This cursor row already exists in the stored array + updateDownload( + cursor, arrayPos, + networkAvailable, networkRoaming, now); + if (shouldScanFile(arrayPos) + && (!mediaScannerConnected() + || !scanFile(cursor, arrayPos))) { + mustScan = true; + keepService = true; + } + if (visibleNotification(arrayPos)) { + keepService = true; + } + long next = nextAction(arrayPos, now); + if (next == 0) { + keepService = true; + } else if (next > 0 && next < wakeUp) { + wakeUp = next; + } + ++arrayPos; + cursor.moveToNext(); + isAfterLast = cursor.isAfterLast(); + } else { + // This cursor entry didn't exist in the stored array + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Array update: appending " + + id + " @ " + arrayPos); + } + insertDownload( + cursor, arrayPos, + networkAvailable, networkRoaming, now); + if (shouldScanFile(arrayPos) + && (!mediaScannerConnected() + || !scanFile(cursor, arrayPos))) { + mustScan = true; + keepService = true; + } + if (visibleNotification(arrayPos)) { + keepService = true; + } + long next = nextAction(arrayPos, now); + if (next == 0) { + keepService = true; + } else if (next > 0 && next < wakeUp) { + wakeUp = next; + } + ++arrayPos; + cursor.moveToNext(); + isAfterLast = cursor.isAfterLast(); + } + } + } + } + + mNotifier.updateNotification(); + + if (mustScan) { + 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); + } + } else { + mMediaScannerConnection.disconnectMediaScanner(); + } + + cursor.close(); + } + } + } + + /** + * Removes files that may have been left behind in the cache directory + */ + private void removeSpuriousFiles() { + File[] files = Environment.getDownloadCacheDirectory().listFiles(); + if (files == null) { + // The cache folder doesn't appear to exist (this is likely the case + // when running the simulator). + return; + } + HashSet fileSet = new HashSet(); + for (int i = 0; i < files.length; i++) { + if (files[i].getName().equals(Constants.KNOWN_SPURIOUS_FILENAME)) { + continue; + } + if (files[i].getName().equalsIgnoreCase(Constants.RECOVERY_DIRECTORY)) { + continue; + } + fileSet.add(files[i].getPath()); + } + + Cursor cursor = getContentResolver().query(Downloads.CONTENT_URI, + new String[] { Downloads._DATA }, null, null, null); + if (cursor != null) { + if (cursor.moveToFirst()) { + do { + fileSet.remove(cursor.getString(0)); + } while (cursor.moveToNext()); + } + cursor.close(); + } + Iterator iterator = fileSet.iterator(); + while (iterator.hasNext()) { + String filename = iterator.next(); + if (Constants.LOGV) { + Log.v(Constants.TAG, "deleting spurious file " + filename); + } + new File(filename).delete(); + } + } + + /** + * Drops old rows from the database to prevent it from growing too large + */ + private void trimDatabase() { + Cursor cursor = getContentResolver().query(Downloads.CONTENT_URI, + new String[] { Downloads._ID }, + Downloads.STATUS + " >= '200'", null, + Downloads.LAST_MODIFICATION); + if (cursor == null) { + // This isn't good - if we can't do basic queries in our database, nothing's gonna work + Log.e(Constants.TAG, "null cursor in trimDatabase"); + return; + } + if (cursor.moveToFirst()) { + int numDelete = cursor.getCount() - Constants.MAX_DOWNLOADS; + int columnId = cursor.getColumnIndexOrThrow(Downloads._ID); + while (numDelete > 0) { + getContentResolver().delete( + ContentUris.withAppendedId(Downloads.CONTENT_URI, cursor.getLong(columnId)), + null, null); + if (!cursor.moveToNext()) { + break; + } + numDelete--; + } + } + cursor.close(); + } + + /** + * Keeps a local copy of the info about a download, and initiates the + * download if appropriate. + */ + private void insertDownload( + Cursor cursor, int arrayPos, + boolean networkAvailable, boolean networkRoaming, long now) { + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int failedColumn = cursor.getColumnIndexOrThrow(Constants.FAILED_CONNECTIONS); + int retryRedirect = + cursor.getInt(cursor.getColumnIndexOrThrow(Constants.RETRY_AFTER___REDIRECT_COUNT)); + DownloadInfo info = new DownloadInfo( + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads._ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.URI)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.NO_INTEGRITY)) == 1, + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.FILENAME_HINT)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads._DATA)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.MIMETYPE)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.DESTINATION)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.VISIBILITY)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.CONTROL)), + cursor.getInt(statusColumn), + cursor.getInt(failedColumn), + retryRedirect & 0xfffffff, + retryRedirect >> 28, + cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.LAST_MODIFICATION)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.NOTIFICATION_PACKAGE)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.NOTIFICATION_CLASS)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.NOTIFICATION_EXTRAS)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.COOKIE_DATA)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.USER_AGENT)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.REFERER)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.TOTAL_BYTES)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.CURRENT_BYTES)), + cursor.getString(cursor.getColumnIndexOrThrow(Constants.ETAG)), + cursor.getInt(cursor.getColumnIndexOrThrow(Constants.MEDIA_SCANNED)) == 1); + + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Service adding new entry"); + Log.v(Constants.TAG, "ID : " + info.id); + Log.v(Constants.TAG, "URI : " + ((info.uri != null) ? "yes" : "no")); + Log.v(Constants.TAG, "NO_INTEG: " + info.noIntegrity); + Log.v(Constants.TAG, "HINT : " + info.hint); + Log.v(Constants.TAG, "FILENAME: " + info.filename); + Log.v(Constants.TAG, "MIMETYPE: " + info.mimetype); + Log.v(Constants.TAG, "DESTINAT: " + info.destination); + Log.v(Constants.TAG, "VISIBILI: " + info.visibility); + Log.v(Constants.TAG, "CONTROL : " + info.control); + Log.v(Constants.TAG, "STATUS : " + info.status); + Log.v(Constants.TAG, "FAILED_C: " + info.numFailed); + Log.v(Constants.TAG, "RETRY_AF: " + info.retryAfter); + Log.v(Constants.TAG, "REDIRECT: " + info.redirectCount); + Log.v(Constants.TAG, "LAST_MOD: " + info.lastMod); + Log.v(Constants.TAG, "PACKAGE : " + info.pckg); + Log.v(Constants.TAG, "CLASS : " + info.clazz); + Log.v(Constants.TAG, "COOKIES : " + ((info.cookies != null) ? "yes" : "no")); + Log.v(Constants.TAG, "AGENT : " + info.userAgent); + Log.v(Constants.TAG, "REFERER : " + ((info.referer != null) ? "yes" : "no")); + Log.v(Constants.TAG, "TOTAL : " + info.totalBytes); + Log.v(Constants.TAG, "CURRENT : " + info.currentBytes); + Log.v(Constants.TAG, "ETAG : " + info.etag); + Log.v(Constants.TAG, "SCANNED : " + info.mediaScanned); + } + + mDownloads.add(arrayPos, info); + + if (info.status == 0 + && (info.destination == Downloads.DESTINATION_EXTERNAL + || info.destination == Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE) + && info.mimetype != null + && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(info.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 mimetypeIntent = 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 + + mimetypeIntent.setDataAndType(Uri.fromParts("file", "", null), info.mimetype); + ResolveInfo ri = getPackageManager().resolveActivity(mimetypeIntent, + PackageManager.MATCH_DEFAULT_ONLY); + //Log.i(Constants.TAG, "*** QUERY " + mimetypeIntent + ": " + list); + + if (ri == null) { + if (Config.LOGD) { + Log.d(Constants.TAG, "no application to handle MIME type " + info.mimetype); + } + info.status = Downloads.STATUS_NOT_ACCEPTABLE; + + Uri uri = ContentUris.withAppendedId(Downloads.CONTENT_URI, info.id); + ContentValues values = new ContentValues(); + values.put(Downloads.STATUS, Downloads.STATUS_NOT_ACCEPTABLE); + getContentResolver().update(uri, values, null, null); + info.sendIntentIfRequested(uri, this); + return; + } + } + + if (info.canUseNetwork(networkAvailable, networkRoaming)) { + if (info.isReadyToStart(now)) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "Service spawning thread to handle new download " + + info.id); + } + if (info.hasActiveThread) { + throw new IllegalStateException("Multiple threads on same download on insert"); + } + if (info.status != Downloads.STATUS_RUNNING) { + info.status = Downloads.STATUS_RUNNING; + ContentValues values = new ContentValues(); + values.put(Downloads.STATUS, info.status); + getContentResolver().update( + ContentUris.withAppendedId(Downloads.CONTENT_URI, info.id), + values, null, null); + } + DownloadThread downloader = new DownloadThread(this, info); + info.hasActiveThread = true; + downloader.start(); + } + } else { + if (info.status == 0 + || info.status == Downloads.STATUS_PENDING + || info.status == Downloads.STATUS_RUNNING) { + info.status = Downloads.STATUS_RUNNING_PAUSED; + Uri uri = ContentUris.withAppendedId(Downloads.CONTENT_URI, info.id); + ContentValues values = new ContentValues(); + values.put(Downloads.STATUS, Downloads.STATUS_RUNNING_PAUSED); + getContentResolver().update(uri, values, null, null); + } + } + } + + /** + * Updates the local copy of the info about a download. + */ + private void updateDownload( + Cursor cursor, int arrayPos, + boolean networkAvailable, boolean networkRoaming, long now) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int failedColumn = cursor.getColumnIndexOrThrow(Constants.FAILED_CONNECTIONS); + info.id = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads._ID)); + info.uri = stringFromCursor(info.uri, cursor, Downloads.URI); + info.noIntegrity = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.NO_INTEGRITY)) == 1; + info.hint = stringFromCursor(info.hint, cursor, Downloads.FILENAME_HINT); + info.filename = stringFromCursor(info.filename, cursor, Downloads._DATA); + info.mimetype = stringFromCursor(info.mimetype, cursor, Downloads.MIMETYPE); + info.destination = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.DESTINATION)); + int newVisibility = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.VISIBILITY)); + if (info.visibility == Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + && newVisibility != Downloads.VISIBILITY_VISIBLE_NOTIFY_COMPLETED + && Downloads.isStatusCompleted(info.status)) { + mNotifier.mNotificationMgr.cancel(info.id); + } + info.visibility = newVisibility; + synchronized(info) { + info.control = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.CONTROL)); + } + int newStatus = cursor.getInt(statusColumn); + if (!Downloads.isStatusCompleted(info.status) && Downloads.isStatusCompleted(newStatus)) { + mNotifier.mNotificationMgr.cancel(info.id); + } + info.status = newStatus; + info.numFailed = cursor.getInt(failedColumn); + int retryRedirect = + cursor.getInt(cursor.getColumnIndexOrThrow(Constants.RETRY_AFTER___REDIRECT_COUNT)); + info.retryAfter = retryRedirect & 0xfffffff; + info.redirectCount = retryRedirect >> 28; + info.lastMod = cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.LAST_MODIFICATION)); + info.pckg = stringFromCursor(info.pckg, cursor, Downloads.NOTIFICATION_PACKAGE); + info.clazz = stringFromCursor(info.clazz, cursor, Downloads.NOTIFICATION_CLASS); + info.cookies = stringFromCursor(info.cookies, cursor, Downloads.COOKIE_DATA); + info.userAgent = stringFromCursor(info.userAgent, cursor, Downloads.USER_AGENT); + info.referer = stringFromCursor(info.referer, cursor, Downloads.REFERER); + info.totalBytes = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.TOTAL_BYTES)); + info.currentBytes = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.CURRENT_BYTES)); + info.etag = stringFromCursor(info.etag, cursor, Constants.ETAG); + info.mediaScanned = + cursor.getInt(cursor.getColumnIndexOrThrow(Constants.MEDIA_SCANNED)) == 1; + + if (info.canUseNetwork(networkAvailable, networkRoaming)) { + if (info.isReadyToRestart(now)) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "Service spawning thread to handle updated download " + + info.id); + } + if (info.hasActiveThread) { + throw new IllegalStateException("Multiple threads on same download on update"); + } + info.status = Downloads.STATUS_RUNNING; + ContentValues values = new ContentValues(); + values.put(Downloads.STATUS, info.status); + getContentResolver().update( + ContentUris.withAppendedId(Downloads.CONTENT_URI, info.id), + values, null, null); + DownloadThread downloader = new DownloadThread(this, info); + info.hasActiveThread = true; + downloader.start(); + } + } + } + + /** + * Returns a String that holds the current value of the column, + * optimizing for the case where the value hasn't changed. + */ + private String stringFromCursor(String old, Cursor cursor, String column) { + int index = cursor.getColumnIndexOrThrow(column); + if (old == null) { + return cursor.getString(index); + } + if (newChars == null) { + newChars = new CharArrayBuffer(128); + } + cursor.copyStringToBuffer(index, newChars); + int length = newChars.sizeCopied; + if (length != old.length()) { + return cursor.getString(index); + } + if (oldChars == null || oldChars.sizeCopied < length) { + oldChars = new CharArrayBuffer(length); + } + char[] oldArray = oldChars.data; + char[] newArray = newChars.data; + old.getChars(0, length, oldArray, 0); + for (int i = length - 1; i >= 0; --i) { + if (oldArray[i] != newArray[i]) { + return new String(newArray, 0, length); + } + } + return old; + } + + /** + * Removes the local copy of the info about a download. + */ + private void deleteDownload(int arrayPos) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + if (info.status == Downloads.STATUS_RUNNING) { + info.status = Downloads.STATUS_CANCELED; + } else if (info.destination != Downloads.DESTINATION_EXTERNAL && info.filename != null) { + new File(info.filename).delete(); + } + mNotifier.mNotificationMgr.cancel(info.id); + + mDownloads.remove(arrayPos); + } + + /** + * 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" + */ + private long nextAction(int arrayPos, long now) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + if (Downloads.isStatusCompleted(info.status)) { + return -1; + } + if (info.status != Downloads.STATUS_RUNNING_PAUSED) { + return 0; + } + if (info.numFailed == 0) { + return 0; + } + long when = info.restartTime(); + if (when <= now) { + return 0; + } + return when - now; + } + + /** + * Returns whether there's a visible notification for this download + */ + private boolean visibleNotification(int arrayPos) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + return info.hasCompletionNotification(); + } + + /** + * Returns whether a file should be scanned + */ + private boolean shouldScanFile(int arrayPos) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + return !info.mediaScanned + && info.destination == Downloads.DESTINATION_EXTERNAL + && Downloads.isStatusSuccess(info.status) + && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(info.mimetype); + } + + /** + * Returns whether we have a live connection to the Media Scanner + */ + private boolean mediaScannerConnected() { + return mMediaScannerService != null; + } + + /** + * Attempts to scan the file if necessary. + * Returns true if the file has been properly scanned. + */ + private boolean scanFile(Cursor cursor, int arrayPos) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + synchronized (this) { + if (mMediaScannerService != null) { + try { + if (Constants.LOGV) { + Log.v(Constants.TAG, "Scanning file " + info.filename); + } + mMediaScannerService.scanFile(info.filename, info.mimetype); + if (cursor != null) { + ContentValues values = new ContentValues(); + values.put(Constants.MEDIA_SCANNED, 1); + getContentResolver().update( + ContentUris.withAppendedId(Downloads.CONTENT_URI, + cursor.getLong(cursor.getColumnIndexOrThrow(Downloads._ID))), + values, null, null); + } + return true; + } catch (RemoteException e) { + if (Config.LOGD) { + Log.d(Constants.TAG, "Failed to scan file " + info.filename); + } + } + } + } + return false; + } + +} diff --git a/src/com/android/providers/downloads/DownloadThread.java b/src/com/android/providers/downloads/DownloadThread.java new file mode 100644 index 00000000..923e36d1 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -0,0 +1,710 @@ +/* + * Copyright (C) 2008 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 org.apache.http.client.methods.AbortableHttpRequest; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.HttpClient; +import org.apache.http.entity.StringEntity; +import org.apache.http.Header; +import org.apache.http.HttpResponse; + +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.drm.mobile1.DrmRawContent; +import android.net.http.AndroidHttpClient; +import android.net.Uri; +import android.os.FileUtils; +import android.os.PowerManager; +import android.os.Process; +import android.provider.Downloads; +import android.provider.DrmStore; +import android.util.Config; +import android.util.Log; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; + +/** + * Runs an actual download + */ +public class DownloadThread extends Thread { + + private Context mContext; + private DownloadInfo mInfo; + + public DownloadThread(Context context, DownloadInfo info) { + mContext = context; + mInfo = info; + } + + /** + * Returns the user agent provided by the initiating app, or use the default one + */ + private String userAgent() { + String userAgent = mInfo.userAgent; + if (userAgent != null) { + } + if (userAgent == null) { + userAgent = Constants.DEFAULT_USER_AGENT; + } + return userAgent; + } + + /** + * Executes the download in a separate thread + */ + public void run() { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + + int finalStatus = Downloads.STATUS_UNKNOWN_ERROR; + boolean countRetry = false; + int retryAfter = 0; + int redirectCount = mInfo.redirectCount; + String newUri = null; + boolean gotData = false; + String filename = null; + String mimeType = mInfo.mimetype; + FileOutputStream stream = null; + AndroidHttpClient client = null; + PowerManager.WakeLock wakeLock = null; + Uri contentUri = Uri.parse(Downloads.CONTENT_URI + "/" + mInfo.id); + + try { + boolean continuingDownload = false; + String headerAcceptRanges = null; + String headerContentDisposition = null; + String headerContentLength = null; + String headerContentLocation = null; + String headerETag = null; + String headerTransferEncoding = null; + + byte data[] = new byte[Constants.BUFFER_SIZE]; + + int bytesSoFar = 0; + + PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG); + wakeLock.acquire(); + + filename = mInfo.filename; + if (filename != null) { + if (!Helpers.isFilenameValid(filename)) { + finalStatus = Downloads.STATUS_FILE_ERROR; + notifyDownloadCompleted( + finalStatus, false, 0, 0, false, filename, null, mInfo.mimetype); + return; + } + // We're resuming a download that got interrupted + File f = new File(filename); + if (f.exists()) { + long fileLength = f.length(); + if (fileLength == 0) { + // The download hadn't actually started, we can restart from scratch + f.delete(); + filename = null; + } else if (mInfo.etag == null && !mInfo.noIntegrity) { + // Tough luck, that's not a resumable download + if (Config.LOGD) { + Log.d(Constants.TAG, + "can't resume interrupted non-resumable download"); + } + f.delete(); + finalStatus = Downloads.STATUS_PRECONDITION_FAILED; + notifyDownloadCompleted( + finalStatus, false, 0, 0, false, filename, null, mInfo.mimetype); + return; + } else { + // All right, we'll be able to resume this download + stream = new FileOutputStream(filename, true); + bytesSoFar = (int) fileLength; + if (mInfo.totalBytes != -1) { + headerContentLength = Integer.toString(mInfo.totalBytes); + } + headerETag = mInfo.etag; + continuingDownload = true; + } + } + } + + int bytesNotified = bytesSoFar; + // starting with MIN_VALUE means that the first write will commit + // progress to the database + long timeLastNotification = 0; + + client = AndroidHttpClient.newInstance(userAgent()); + + if (stream != null && mInfo.destination == Downloads.DESTINATION_EXTERNAL + && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING + .equalsIgnoreCase(mimeType)) { + try { + stream.close(); + stream = null; + } catch (IOException ex) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "exception when closing the file before download : " + + ex); + } + // nothing can really be done if the file can't be closed + } + } + + /* + * This loop is run once for every individual HTTP request that gets sent. + * The very first HTTP request is a "virgin" request, while every subsequent + * request is done with the original ETag and a byte-range. + */ +http_request_loop: + while (true) { + // Prepares the request and fires it. + HttpGet request = new HttpGet(mInfo.uri); + + if (Constants.LOGV) { + Log.v(Constants.TAG, "initiating download for " + mInfo.uri); + } + + if (mInfo.cookies != null) { + request.addHeader("Cookie", mInfo.cookies); + } + if (mInfo.referer != null) { + request.addHeader("Referer", mInfo.referer); + } + if (continuingDownload) { + if (headerETag != null) { + request.addHeader("If-Match", headerETag); + } + request.addHeader("Range", "bytes=" + bytesSoFar + "-"); + } + + HttpResponse response; + try { + response = client.execute(request); + } catch (IllegalArgumentException ex) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "Arg exception trying to execute request for " + + mInfo.uri + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "Arg exception trying to execute request for " + + mInfo.id + " : " + ex); + } + finalStatus = Downloads.STATUS_BAD_REQUEST; + request.abort(); + break http_request_loop; + } catch (IOException ex) { + if (!Helpers.isNetworkAvailable(mContext)) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + } else if (mInfo.numFailed < Constants.MAX_RETRIES) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + countRetry = true; + } else { + if (Constants.LOGV) { + Log.d(Constants.TAG, "IOException trying to execute request for " + + mInfo.uri + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "IOException trying to execute request for " + + mInfo.id + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + request.abort(); + break http_request_loop; + } + + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == 503 && mInfo.numFailed < Constants.MAX_RETRIES) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "got HTTP response code 503"); + } + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + countRetry = true; + Header header = response.getFirstHeader("Retry-After"); + if (header != null) { + try { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Retry-After :" + header.getValue()); + } + retryAfter = Integer.parseInt(header.getValue()); + if (retryAfter < 0) { + retryAfter = 0; + } else { + if (retryAfter < Constants.MIN_RETRY_AFTER) { + retryAfter = Constants.MIN_RETRY_AFTER; + } else if (retryAfter > Constants.MAX_RETRY_AFTER) { + retryAfter = Constants.MAX_RETRY_AFTER; + } + retryAfter += Helpers.rnd.nextInt(Constants.MIN_RETRY_AFTER + 1); + retryAfter *= 1000; + } + } catch (NumberFormatException ex) { + // ignored - retryAfter stays 0 in this case. + } + } + request.abort(); + break http_request_loop; + } + if (statusCode == 301 || + statusCode == 302 || + statusCode == 303 || + statusCode == 307) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "got HTTP redirect " + statusCode); + } + if (redirectCount >= Constants.MAX_REDIRECTS) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "too many redirects for download " + mInfo.id + + " at " + mInfo.uri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "too many redirects for download " + mInfo.id); + } + finalStatus = Downloads.STATUS_TOO_MANY_REDIRECTS; + request.abort(); + break http_request_loop; + } + Header header = response.getFirstHeader("Location"); + if (header != null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Location :" + header.getValue()); + } + newUri = new URI(mInfo.uri).resolve(new URI(header.getValue())).toString(); + ++redirectCount; + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + request.abort(); + break http_request_loop; + } + } + if ((!continuingDownload && statusCode != Downloads.STATUS_SUCCESS) + || (continuingDownload && statusCode != 206)) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "http error " + statusCode + " for " + mInfo.uri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "http error " + statusCode + " for download " + + mInfo.id); + } + if (Downloads.isStatusError(statusCode)) { + finalStatus = statusCode; + } else if (statusCode >= 300 && statusCode < 400) { + finalStatus = Downloads.STATUS_UNHANDLED_REDIRECT; + } else if (continuingDownload && statusCode == Downloads.STATUS_SUCCESS) { + finalStatus = Downloads.STATUS_PRECONDITION_FAILED; + } else { + finalStatus = Downloads.STATUS_UNHANDLED_HTTP_CODE; + } + request.abort(); + break http_request_loop; + } else { + // Handles the response, saves the file + if (Constants.LOGV) { + Log.v(Constants.TAG, "received response for " + mInfo.uri); + } + + if (!continuingDownload) { + Header header = response.getFirstHeader("Accept-Ranges"); + if (header != null) { + headerAcceptRanges = header.getValue(); + } + header = response.getFirstHeader("Content-Disposition"); + if (header != null) { + headerContentDisposition = header.getValue(); + } + header = response.getFirstHeader("Content-Location"); + if (header != null) { + headerContentLocation = header.getValue(); + } + if (mimeType == null) { + header = response.getFirstHeader("Content-Type"); + if (header != null) { + mimeType = header.getValue(); + final int semicolonIndex = mimeType.indexOf(';'); + if (semicolonIndex != -1) { + mimeType = mimeType.substring(0, semicolonIndex); + } + } + } + header = response.getFirstHeader("ETag"); + if (header != null) { + headerETag = header.getValue(); + } + header = response.getFirstHeader("Transfer-Encoding"); + if (header != null) { + headerTransferEncoding = header.getValue(); + } + if (headerTransferEncoding == null) { + header = response.getFirstHeader("Content-Length"); + if (header != null) { + headerContentLength = header.getValue(); + } + } else { + // Ignore content-length with transfer-encoding - 2616 4.4 3 + if (Constants.LOGVV) { + Log.v(Constants.TAG, + "ignoring content-length because of xfer-encoding"); + } + } + if (Constants.LOGVV) { + Log.v(Constants.TAG, "Accept-Ranges: " + headerAcceptRanges); + Log.v(Constants.TAG, "Content-Disposition: " + + headerContentDisposition); + Log.v(Constants.TAG, "Content-Length: " + headerContentLength); + Log.v(Constants.TAG, "Content-Location: " + headerContentLocation); + Log.v(Constants.TAG, "Content-Type: " + mimeType); + Log.v(Constants.TAG, "ETag: " + headerETag); + Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding); + } + + if (!mInfo.noIntegrity && headerContentLength == null && + (headerTransferEncoding == null + || !headerTransferEncoding.equalsIgnoreCase("chunked")) + ) { + if (Config.LOGD) { + Log.d(Constants.TAG, "can't know size of download, giving up"); + } + finalStatus = Downloads.STATUS_LENGTH_REQUIRED; + request.abort(); + break http_request_loop; + } + + DownloadFileInfo fileInfo = Helpers.generateSaveFile( + mContext, + mInfo.uri, + mInfo.hint, + headerContentDisposition, + headerContentLocation, + mimeType, + mInfo.destination, + (headerContentLength != null) ? + Integer.parseInt(headerContentLength) : 0); + if (fileInfo.filename == null) { + finalStatus = fileInfo.status; + request.abort(); + break http_request_loop; + } + filename = fileInfo.filename; + stream = fileInfo.stream; + if (Constants.LOGV) { + Log.v(Constants.TAG, "writing " + mInfo.uri + " to " + filename); + } + + ContentValues values = new ContentValues(); + values.put(Downloads._DATA, filename); + if (headerETag != null) { + values.put(Constants.ETAG, headerETag); + } + if (mimeType != null) { + values.put(Downloads.MIMETYPE, mimeType); + } + int contentLength = -1; + if (headerContentLength != null) { + contentLength = Integer.parseInt(headerContentLength); + } + values.put(Downloads.TOTAL_BYTES, contentLength); + mContext.getContentResolver().update(contentUri, values, null, null); + } + + InputStream entityStream; + try { + entityStream = response.getEntity().getContent(); + } catch (IOException ex) { + if (!Helpers.isNetworkAvailable(mContext)) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + } else if (mInfo.numFailed < Constants.MAX_RETRIES) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + countRetry = true; + } else { + if (Constants.LOGV) { + Log.d(Constants.TAG, "IOException getting entity for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "IOException getting entity for download " + + mInfo.id + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + request.abort(); + break http_request_loop; + } + for (;;) { + int bytesRead; + try { + bytesRead = entityStream.read(data); + } catch (IOException ex) { + ContentValues values = new ContentValues(); + values.put(Downloads.CURRENT_BYTES, bytesSoFar); + mContext.getContentResolver().update(contentUri, values, null, null); + if (!mInfo.noIntegrity && headerETag == null) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "download IOException for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "download IOException for download " + + mInfo.id + " : " + ex); + } + if (Config.LOGD) { + Log.d(Constants.TAG, + "can't resume interrupted download with no ETag"); + } + finalStatus = Downloads.STATUS_PRECONDITION_FAILED; + } else if (!Helpers.isNetworkAvailable(mContext)) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + } else if (mInfo.numFailed < Constants.MAX_RETRIES) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + countRetry = true; + } else { + if (Constants.LOGV) { + Log.v(Constants.TAG, "download IOException for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "download IOException for download " + + mInfo.id + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + request.abort(); + break http_request_loop; + } + if (bytesRead == -1) { // success + ContentValues values = new ContentValues(); + values.put(Downloads.CURRENT_BYTES, bytesSoFar); + if (headerContentLength == null) { + values.put(Downloads.TOTAL_BYTES, bytesSoFar); + } + mContext.getContentResolver().update(contentUri, values, null, null); + if ((headerContentLength != null) + && (bytesSoFar + != Integer.parseInt(headerContentLength))) { + if (!mInfo.noIntegrity && headerETag == null) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "mismatched content length " + + mInfo.uri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "mismatched content length for " + + mInfo.id); + } + finalStatus = Downloads.STATUS_LENGTH_REQUIRED; + } else if (!Helpers.isNetworkAvailable(mContext)) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + } else if (mInfo.numFailed < Constants.MAX_RETRIES) { + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + countRetry = true; + } else { + if (Constants.LOGV) { + Log.v(Constants.TAG, "closed socket for " + mInfo.uri); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "closed socket for download " + + mInfo.id); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + break http_request_loop; + } + break; + } + gotData = true; + for (;;) { + try { + if (stream == null) { + stream = new FileOutputStream(filename, true); + } + stream.write(data, 0, bytesRead); + if (mInfo.destination == Downloads.DESTINATION_EXTERNAL + && !DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING + .equalsIgnoreCase(mimeType)) { + try { + stream.close(); + stream = null; + } catch (IOException ex) { + if (Constants.LOGV) { + Log.v(Constants.TAG, + "exception when closing the file " + + "during download : " + ex); + } + // nothing can really be done if the file can't be closed + } + } + break; + } catch (IOException ex) { + if (!Helpers.discardPurgeableFiles( + mContext, Constants.BUFFER_SIZE)) { + finalStatus = Downloads.STATUS_FILE_ERROR; + break http_request_loop; + } + } + } + bytesSoFar += bytesRead; + long now = System.currentTimeMillis(); + if (bytesSoFar - bytesNotified > Constants.MIN_PROGRESS_STEP + && now - timeLastNotification + > Constants.MIN_PROGRESS_TIME) { + ContentValues values = new ContentValues(); + values.put(Downloads.CURRENT_BYTES, bytesSoFar); + mContext.getContentResolver().update( + contentUri, values, null, null); + bytesNotified = bytesSoFar; + timeLastNotification = now; + } + + if (Constants.LOGVV) { + Log.v(Constants.TAG, "downloaded " + bytesSoFar + " for " + mInfo.uri); + } + synchronized(mInfo) { + if (mInfo.control == Downloads.CONTROL_PAUSED) { + if (Constants.LOGV) { + Log.v(Constants.TAG, "paused " + mInfo.uri); + } + finalStatus = Downloads.STATUS_RUNNING_PAUSED; + request.abort(); + break http_request_loop; + } + } + if (mInfo.status == Downloads.STATUS_CANCELED) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "canceled " + mInfo.uri); + } else if (Config.LOGD) { + // Log.d(Constants.TAG, "canceled id " + mInfo.id); + } + finalStatus = Downloads.STATUS_CANCELED; + break http_request_loop; + } + } + if (Constants.LOGV) { + Log.v(Constants.TAG, "download completed for " + mInfo.uri); + } + finalStatus = Downloads.STATUS_SUCCESS; + } + break; + } + } catch (FileNotFoundException ex) { + if (Config.LOGD) { + Log.d(Constants.TAG, "FileNotFoundException for " + filename + " : " + ex); + } + finalStatus = Downloads.STATUS_FILE_ERROR; + // falls through to the code that reports an error + } catch (Exception ex) { //sometimes the socket code throws unchecked exceptions + if (Constants.LOGV) { + Log.d(Constants.TAG, "Exception for " + mInfo.uri, ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "Exception for id " + mInfo.id, ex); + } + finalStatus = Downloads.STATUS_UNKNOWN_ERROR; + // falls through to the code that reports an error + } finally { + mInfo.hasActiveThread = false; + if (wakeLock != null) { + wakeLock.release(); + wakeLock = null; + } + if (client != null) { + client.close(); + client = null; + } + try { + // close the file + if (stream != null) { + stream.close(); + } + } 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 + } + if (filename != null) { + // if the download wasn't successful, delete the file + if (Downloads.isStatusError(finalStatus)) { + new File(filename).delete(); + filename = null; + } else if (Downloads.isStatusSuccess(finalStatus) && + DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING + .equalsIgnoreCase(mimeType)) { + // transfer the file to the DRM content provider + File file = new File(filename); + Intent item = DrmStore.addDrmFile(mContext.getContentResolver(), file, null); + if (item == null) { + Log.w(Constants.TAG, "unable to add file " + filename + " to DrmProvider"); + finalStatus = Downloads.STATUS_UNKNOWN_ERROR; + } else { + filename = item.getDataString(); + mimeType = item.getType(); + } + + file.delete(); + } else if (Downloads.isStatusSuccess(finalStatus)) { + // make sure the file is readable + FileUtils.setPermissions(filename, 0644, -1, -1); + } + } + notifyDownloadCompleted(finalStatus, countRetry, retryAfter, redirectCount, + gotData, filename, newUri, mimeType); + } + } + + /** + * Stores information about the completed download, and notifies the initiating application. + */ + private void notifyDownloadCompleted( + int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData, + String filename, String uri, String mimeType) { + notifyThroughDatabase( + status, countRetry, retryAfter, redirectCount, gotData, filename, uri, mimeType); + if (Downloads.isStatusCompleted(status)) { + notifyThroughIntent(); + } + } + + private void notifyThroughDatabase( + int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData, + String filename, String uri, String mimeType) { + ContentValues values = new ContentValues(); + values.put(Downloads.STATUS, status); + values.put(Downloads._DATA, filename); + if (uri != null) { + values.put(Downloads.URI, uri); + } + values.put(Downloads.MIMETYPE, mimeType); + values.put(Downloads.LAST_MODIFICATION, System.currentTimeMillis()); + values.put(Constants.RETRY_AFTER___REDIRECT_COUNT, retryAfter + (redirectCount << 28)); + if (!countRetry) { + values.put(Constants.FAILED_CONNECTIONS, 0); + } else if (gotData) { + values.put(Constants.FAILED_CONNECTIONS, 1); + } else { + values.put(Constants.FAILED_CONNECTIONS, mInfo.numFailed + 1); + } + + mContext.getContentResolver().update( + ContentUris.withAppendedId(Downloads.CONTENT_URI, mInfo.id), values, null, null); + } + + /** + * Notifies the initiating app if it requested it. That way, it can know that the + * download completed even if it's not actively watching the cursor. + */ + private void notifyThroughIntent() { + Uri uri = Uri.parse(Downloads.CONTENT_URI + "/" + mInfo.id); + mInfo.sendIntentIfRequested(uri, mContext); + } + +} diff --git a/src/com/android/providers/downloads/Helpers.java b/src/com/android/providers/downloads/Helpers.java new file mode 100644 index 00000000..7c6070f3 --- /dev/null +++ b/src/com/android/providers/downloads/Helpers.java @@ -0,0 +1,793 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.downloads; + +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.database.Cursor; +import android.drm.mobile1.DrmRawContent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Environment; +import android.os.StatFs; +import android.os.SystemClock; +import android.provider.Downloads; +import android.telephony.TelephonyManager; +import android.util.Config; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.List; +import java.util.Random; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.Set; + +/** + * Some helper functions for the download manager + */ +public class Helpers { + + public static Random rnd = new Random(SystemClock.uptimeMillis()); + + /** Regex used to parse content-disposition headers */ + private static final Pattern CONTENT_DISPOSITION_PATTERN = + Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\""); + + private Helpers() { + } + + /* + * Parse the Content-Disposition HTTP Header. The format of the header + * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html + * This header provides a filename for content that is going to be + * downloaded to the file system. We only support the attachment type. + */ + private static String parseContentDisposition(String contentDisposition) { + try { + Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition); + if (m.find()) { + return m.group(1); + } + } catch (IllegalStateException ex) { + // This function is defined as returning null when it can't parse the header + } + return null; + } + + /** + * Creates a filename (where the file should be saved) from a uri. + */ + public static DownloadFileInfo generateSaveFile( + Context context, + String url, + String hint, + String contentDisposition, + String contentLocation, + String mimeType, + int destination, + int contentLength) throws FileNotFoundException { + + /* + * Don't download files that we won't be able to handle + */ + if (destination == Downloads.DESTINATION_EXTERNAL + || destination == Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE) { + if (mimeType == null) { + if (Config.LOGD) { + Log.d(Constants.TAG, "external download with no mime type not allowed"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_NOT_ACCEPTABLE); + } + if (!DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(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 (Config.LOGD) { + Log.d(Constants.TAG, "no handler found for type " + mimeType); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_NOT_ACCEPTABLE); + } + } + } + String filename = chooseFilename( + url, hint, contentDisposition, contentLocation, destination); + + // Split filename between base and extension + // Add an extension if filename does not have one + String extension = null; + int dotIndex = filename.indexOf('.'); + if (dotIndex < 0) { + extension = chooseExtensionFromMimeType(mimeType, true); + } else { + extension = chooseExtensionFromFilename( + mimeType, destination, filename, dotIndex); + filename = filename.substring(0, dotIndex); + } + + /* + * Locate the directory where the file will be saved + */ + + File base = null; + StatFs stat = null; + // DRM messages should be temporarily stored internally and then passed to + // the DRM content provider + if (destination == Downloads.DESTINATION_CACHE_PARTITION + || destination == Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE + || destination == Downloads.DESTINATION_CACHE_PARTITION_NOROAMING + || DrmRawContent.DRM_MIMETYPE_MESSAGE_STRING.equalsIgnoreCase(mimeType)) { + base = Environment.getDownloadCacheDirectory(); + stat = new StatFs(base.getPath()); + + /* + * Check whether there's enough space on the target filesystem to save the file. + * Put a bit of margin (in case creating the file grows the system by a few blocks). + */ + int blockSize = stat.getBlockSize(); + for (;;) { + int availableBlocks = stat.getAvailableBlocks(); + if (blockSize * ((long) availableBlocks - 4) >= contentLength) { + break; + } + if (!discardPurgeableFiles(context, + contentLength - blockSize * ((long) availableBlocks - 4))) { + if (Config.LOGD) { + Log.d(Constants.TAG, + "download aborted - not enough free space in internal storage"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + stat.restat(base.getPath()); + } + + } else { + if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + String root = Environment.getExternalStorageDirectory().getPath(); + base = new File(root + Constants.DEFAULT_DL_SUBDIR); + if (!base.isDirectory() && !base.mkdir()) { + if (Config.LOGD) { + Log.d(Constants.TAG, "download aborted - can't create base directory " + + base.getPath()); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + stat = new StatFs(base.getPath()); + } else { + if (Config.LOGD) { + Log.d(Constants.TAG, "download aborted - no external storage"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + + /* + * Check whether there's enough space on the target filesystem to save the file. + * Put a bit of margin (in case creating the file grows the system by a few blocks). + */ + if (stat.getBlockSize() * ((long) stat.getAvailableBlocks() - 4) < contentLength) { + if (Config.LOGD) { + Log.d(Constants.TAG, "download aborted - not enough free space"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + + } + + boolean recoveryDir = Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(filename + extension); + + filename = base.getPath() + File.separator + filename; + + /* + * Generate a unique filename, create the file, return it. + */ + if (Constants.LOGVV) { + Log.v(Constants.TAG, "target file: " + filename + extension); + } + + String fullFilename = chooseUniqueFilename( + destination, filename, extension, recoveryDir); + if (fullFilename != null) { + return new DownloadFileInfo(fullFilename, new FileOutputStream(fullFilename), 0); + } else { + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + } + + private static String chooseFilename(String url, String hint, String contentDisposition, + String contentLocation, int destination) { + String filename = null; + + // First, try to use the hint from the application, if there's one + if (filename == null && hint != null && !hint.endsWith("/")) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "getting filename from hint"); + } + int index = hint.lastIndexOf('/') + 1; + if (index > 0) { + filename = hint.substring(index); + } else { + filename = hint; + } + } + + // If we couldn't do anything with the hint, move toward the content disposition + if (filename == null && contentDisposition != null) { + filename = parseContentDisposition(contentDisposition); + if (filename != null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "getting filename from content-disposition"); + } + int index = filename.lastIndexOf('/') + 1; + if (index > 0) { + filename = filename.substring(index); + } + } + } + + // If we still have nothing at this point, try the content location + if (filename == null && contentLocation != null) { + String decodedContentLocation = Uri.decode(contentLocation); + if (decodedContentLocation != null + && !decodedContentLocation.endsWith("/") + && decodedContentLocation.indexOf('?') < 0) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "getting filename from content-location"); + } + int index = decodedContentLocation.lastIndexOf('/') + 1; + if (index > 0) { + filename = decodedContentLocation.substring(index); + } else { + filename = decodedContentLocation; + } + } + } + + // If all the other http-related approaches failed, use the plain uri + if (filename == null) { + String decodedUrl = Uri.decode(url); + if (decodedUrl != null + && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) { + int index = decodedUrl.lastIndexOf('/') + 1; + if (index > 0) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "getting filename from uri"); + } + filename = decodedUrl.substring(index); + } + } + } + + // Finally, if couldn't get filename from URI, get a generic filename + if (filename == null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "using default filename"); + } + filename = Constants.DEFAULT_DL_FILENAME; + } + + filename = filename.replaceAll("[^a-zA-Z0-9\\.\\-_]+", "_"); + + + return filename; + } + + private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) { + String extension = null; + if (mimeType != null) { + extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if (extension != null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "adding extension from type"); + } + extension = "." + extension; + } else { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "couldn't find extension for " + mimeType); + } + } + } + if (extension == null) { + if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) { + if (mimeType.equalsIgnoreCase("text/html")) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "adding default html extension"); + } + extension = Constants.DEFAULT_DL_HTML_EXTENSION; + } else if (useDefaults) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "adding default text extension"); + } + extension = Constants.DEFAULT_DL_TEXT_EXTENSION; + } + } else if (useDefaults) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "adding default binary extension"); + } + extension = Constants.DEFAULT_DL_BINARY_EXTENSION; + } + } + return extension; + } + + private static String chooseExtensionFromFilename(String mimeType, int destination, + String filename, int dotIndex) { + String extension = null; + if (mimeType != null) { + // Compare the last segment of the extension against the mime type. + // If there's a mismatch, discard the entire extension. + int lastDotIndex = filename.lastIndexOf('.'); + String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension( + filename.substring(lastDotIndex + 1)); + if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) { + extension = chooseExtensionFromMimeType(mimeType, false); + if (extension != null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "substituting extension from type"); + } + } else { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "couldn't find extension for " + mimeType); + } + } + } + } + if (extension == null) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "keeping extension"); + } + extension = filename.substring(dotIndex); + } + return extension; + } + + private static String chooseUniqueFilename(int destination, String filename, + String extension, boolean recoveryDir) { + String fullFilename = filename + extension; + if (!new File(fullFilename).exists() + && (!recoveryDir || + (destination != Downloads.DESTINATION_CACHE_PARTITION && + destination != Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE && + destination != Downloads.DESTINATION_CACHE_PARTITION_NOROAMING))) { + return fullFilename; + } + filename = filename + Constants.FILENAME_SEQUENCE_SEPARATOR; + /* + * This number is used to generate partially randomized filenames to avoid + * collisions. + * It starts at 1. + * The next 9 iterations increment it by 1 at a time (up to 10). + * The next 9 iterations increment it by 1 to 10 (random) at a time. + * The next 9 iterations increment it by 1 to 100 (random) at a time. + * ... Up to the point where it increases by 100000000 at a time. + * (the maximum value that can be reached is 1000000000) + * As soon as a number is reached that generates a filename that doesn't exist, + * that filename is used. + * If the filename coming in is [base].[ext], the generated filenames are + * [base]-[sequence].[ext]. + */ + int sequence = 1; + for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) { + for (int iteration = 0; iteration < 9; ++iteration) { + fullFilename = filename + sequence + extension; + if (!new File(fullFilename).exists()) { + return fullFilename; + } + if (Constants.LOGVV) { + Log.v(Constants.TAG, "file with sequence number " + sequence + " exists"); + } + sequence += rnd.nextInt(magnitude) + 1; + } + } + return null; + } + + /** + * Deletes purgeable files from the cache partition. This also deletes + * the matching database entries. Files are deleted in LRU order until + * the total byte size is greater than targetBytes. + */ + public static final boolean discardPurgeableFiles(Context context, long targetBytes) { + Cursor cursor = context.getContentResolver().query( + Downloads.CONTENT_URI, + null, + "( " + + Downloads.STATUS + " = " + Downloads.STATUS_SUCCESS + " AND " + + Downloads.DESTINATION + " = " + Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE + + " )", + null, + Downloads.LAST_MODIFICATION); + if (cursor == null) { + return false; + } + long totalFreed = 0; + try { + cursor.moveToFirst(); + while (!cursor.isAfterLast() && totalFreed < targetBytes) { + File file = new File(cursor.getString(cursor.getColumnIndex(Downloads._DATA))); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " + + file.length() + " bytes"); + } + totalFreed += file.length(); + file.delete(); + long id = cursor.getLong(cursor.getColumnIndex(Downloads._ID)); + context.getContentResolver().delete( + ContentUris.withAppendedId(Downloads.CONTENT_URI, id), null, null); + cursor.moveToNext(); + } + } finally { + cursor.close(); + } + if (Constants.LOGV) { + if (totalFreed > 0) { + Log.v(Constants.TAG, "Purged files, freed " + totalFreed + " for " + + targetBytes + " requested"); + } + } + return totalFreed > 0; + } + + /** + * Returns whether the network is available + */ + public static boolean isNetworkAvailable(Context context) { + ConnectivityManager connectivity = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivity == null) { + Log.w(Constants.TAG, "couldn't get connectivity manager"); + } else { + NetworkInfo[] info = connectivity.getAllNetworkInfo(); + if (info != null) { + for (int i = 0; i < info.length; i++) { + if (info[i].getState() == NetworkInfo.State.CONNECTED) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "network is available"); + } + return true; + } + } + } + } + if (Constants.LOGVV) { + Log.v(Constants.TAG, "network is not available"); + } + return false; + } + + /** + * Returns whether the network is roaming + */ + public static boolean isNetworkRoaming(Context context) { + ConnectivityManager connectivity = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivity == null) { + Log.w(Constants.TAG, "couldn't get connectivity manager"); + } else { + NetworkInfo info = connectivity.getActiveNetworkInfo(); + if (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE) { + if (TelephonyManager.getDefault().isNetworkRoaming()) { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "network is roaming"); + } + return true; + } else { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "network is not roaming"); + } + } + } else { + if (Constants.LOGVV) { + Log.v(Constants.TAG, "not using mobile network"); + } + } + } + return false; + } + + /** + * Checks whether the filename looks legitimate + */ + public static boolean isFilenameValid(String filename) { + File dir = new File(filename).getParentFile(); + return dir.equals(Environment.getDownloadCacheDirectory()) + || dir.equals(new File(Environment.getExternalStorageDirectory() + + Constants.DEFAULT_DL_SUBDIR)); + } + + /** + * Checks whether this looks like a legitimate selection parameter + */ + public static void validateSelection(String selection, Set allowedColumns) { + try { + if (selection == null) { + return; + } + Lexer lexer = new Lexer(selection, allowedColumns); + parseExpression(lexer); + if (lexer.currentToken() != Lexer.TOKEN_END) { + throw new IllegalArgumentException("syntax error"); + } + } catch (RuntimeException ex) { + if (Constants.LOGV) { + Log.d(Constants.TAG, "invalid selection [" + selection + "] triggered " + ex); + } else if (Config.LOGD) { + Log.d(Constants.TAG, "invalid selection triggered " + ex); + } + throw ex; + } + + } + + // expression <- ( expression ) | statement [AND_OR ( expression ) | statement] * + // | statement [AND_OR expression]* + private static void parseExpression(Lexer lexer) { + for (;;) { + // ( expression ) + if (lexer.currentToken() == Lexer.TOKEN_OPEN_PAREN) { + lexer.advance(); + parseExpression(lexer); + if (lexer.currentToken() != Lexer.TOKEN_CLOSE_PAREN) { + throw new IllegalArgumentException("syntax error, unmatched parenthese"); + } + lexer.advance(); + } else { + // statement + parseStatement(lexer); + } + if (lexer.currentToken() != Lexer.TOKEN_AND_OR) { + break; + } + lexer.advance(); + } + } + + // statement <- COLUMN COMPARE VALUE + // | COLUMN IS NULL + private static void parseStatement(Lexer lexer) { + // both possibilities start with COLUMN + if (lexer.currentToken() != Lexer.TOKEN_COLUMN) { + throw new IllegalArgumentException("syntax error, expected column name"); + } + lexer.advance(); + + // statement <- COLUMN COMPARE VALUE + if (lexer.currentToken() == Lexer.TOKEN_COMPARE) { + lexer.advance(); + if (lexer.currentToken() != Lexer.TOKEN_VALUE) { + throw new IllegalArgumentException("syntax error, expected quoted string"); + } + lexer.advance(); + return; + } + + // statement <- COLUMN IS NULL + if (lexer.currentToken() == Lexer.TOKEN_IS) { + lexer.advance(); + if (lexer.currentToken() != Lexer.TOKEN_NULL) { + throw new IllegalArgumentException("syntax error, expected NULL"); + } + lexer.advance(); + return; + } + + // didn't get anything good after COLUMN + throw new IllegalArgumentException("syntax error after column name"); + } + + /** + * A simple lexer that recognizes the words of our restricted subset of SQL where clauses + */ + private static class Lexer { + public static final int TOKEN_START = 0; + public static final int TOKEN_OPEN_PAREN = 1; + public static final int TOKEN_CLOSE_PAREN = 2; + public static final int TOKEN_AND_OR = 3; + public static final int TOKEN_COLUMN = 4; + public static final int TOKEN_COMPARE = 5; + public static final int TOKEN_VALUE = 6; + public static final int TOKEN_IS = 7; + public static final int TOKEN_NULL = 8; + public static final int TOKEN_END = 9; + + private final String mSelection; + private final Set mAllowedColumns; + private int mOffset = 0; + private int mCurrentToken = TOKEN_START; + private final char[] mChars; + + public Lexer(String selection, Set allowedColumns) { + mSelection = selection; + mAllowedColumns = allowedColumns; + mChars = new char[mSelection.length()]; + mSelection.getChars(0, mChars.length, mChars, 0); + advance(); + } + + public int currentToken() { + return mCurrentToken; + } + + public void advance() { + char[] chars = mChars; + + // consume whitespace + while (mOffset < chars.length && chars[mOffset] == ' ') { + ++mOffset; + } + + // end of input + if (mOffset == chars.length) { + mCurrentToken = TOKEN_END; + return; + } + + // "(" + if (chars[mOffset] == '(') { + ++mOffset; + mCurrentToken = TOKEN_OPEN_PAREN; + return; + } + + // ")" + if (chars[mOffset] == ')') { + ++mOffset; + mCurrentToken = TOKEN_CLOSE_PAREN; + return; + } + + // "?" + if (chars[mOffset] == '?') { + ++mOffset; + mCurrentToken = TOKEN_VALUE; + return; + } + + // "=" and "==" + if (chars[mOffset] == '=') { + ++mOffset; + mCurrentToken = TOKEN_COMPARE; + if (mOffset < chars.length && chars[mOffset] == '=') { + ++mOffset; + } + return; + } + + // ">" and ">=" + if (chars[mOffset] == '>') { + ++mOffset; + mCurrentToken = TOKEN_COMPARE; + if (mOffset < chars.length && chars[mOffset] == '=') { + ++mOffset; + } + return; + } + + // "<", "<=" and "<>" + if (chars[mOffset] == '<') { + ++mOffset; + mCurrentToken = TOKEN_COMPARE; + if (mOffset < chars.length && (chars[mOffset] == '=' || chars[mOffset] == '>')) { + ++mOffset; + } + return; + } + + // "!=" + if (chars[mOffset] == '!') { + ++mOffset; + mCurrentToken = TOKEN_COMPARE; + if (mOffset < chars.length && chars[mOffset] == '=') { + ++mOffset; + return; + } + throw new IllegalArgumentException("Unexpected character after !"); + } + + // columns and keywords + // first look for anything that looks like an identifier or a keyword + // and then recognize the individual words. + // no attempt is made at discarding sequences of underscores with no alphanumeric + // characters, even though it's not clear that they'd be legal column names. + if (isIdentifierStart(chars[mOffset])) { + int startOffset = mOffset; + ++mOffset; + while (mOffset < chars.length && isIdentifierChar(chars[mOffset])) { + ++mOffset; + } + String word = mSelection.substring(startOffset, mOffset); + if (mOffset - startOffset <= 4) { + if (word.equals("IS")) { + mCurrentToken = TOKEN_IS; + return; + } + if (word.equals("OR") || word.equals("AND")) { + mCurrentToken = TOKEN_AND_OR; + return; + } + if (word.equals("NULL")) { + mCurrentToken = TOKEN_NULL; + return; + } + } + if (mAllowedColumns.contains(word)) { + mCurrentToken = TOKEN_COLUMN; + return; + } + throw new IllegalArgumentException("unrecognized column or keyword"); + } + + // quoted strings + if (chars[mOffset] == '\'') { + ++mOffset; + while(mOffset < chars.length) { + if (chars[mOffset] == '\'') { + if (mOffset + 1 < chars.length && chars[mOffset + 1] == '\'') { + ++mOffset; + } else { + break; + } + } + ++mOffset; + } + if (mOffset == chars.length) { + throw new IllegalArgumentException("unterminated string"); + } + ++mOffset; + mCurrentToken = TOKEN_VALUE; + return; + } + + // anything we don't recognize + throw new IllegalArgumentException("illegal character"); + } + + private static final boolean isIdentifierStart(char c) { + return c == '_' || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z'); + } + + private static final boolean isIdentifierChar(char c) { + return c == '_' || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + } + } +} -- cgit v1.2.3