From 57f55b3cb4f7e4136cde8d1ea12c1e70ec903362 Mon Sep 17 00:00:00 2001 From: The Android Open Source Project Date: Tue, 21 Oct 2008 07:00:00 -0700 Subject: Initial Contribution --- src/com/android/providers/downloads/Constants.java | 122 +++ .../providers/downloads/DownloadFileInfo.java | 34 + .../android/providers/downloads/DownloadInfo.java | 185 +++++ .../providers/downloads/DownloadNotification.java | 300 +++++++ .../providers/downloads/DownloadProvider.java | 532 +++++++++++++ .../providers/downloads/DownloadReceiver.java | 167 ++++ .../providers/downloads/DownloadService.java | 859 +++++++++++++++++++++ .../providers/downloads/DownloadThread.java | 643 +++++++++++++++ src/com/android/providers/downloads/Helpers.java | 510 ++++++++++++ 9 files changed, 3352 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') diff --git a/src/com/android/providers/downloads/Constants.java b/src/com/android/providers/downloads/Constants.java new file mode 100644 index 00000000..f3dd08c7 --- /dev/null +++ b/src/com/android/providers/downloads/Constants.java @@ -0,0 +1,122 @@ +/* + * 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 permission that allows to access data about all downloads */ + public static final String UI_PERMISSION = "android.permission.ACCESS_DOWNLOAD_DATA"; + + /** The permission that allows to download a system image */ + public static final String OTA_UPDATE_PERMISSION = "android.permission.DOWNLOAD_OTA_UPDATE"; + + /** 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 magic filename for OTA updates */ + public static final String OTA_UPDATE_FILENAME = "update.install"; + + /** 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 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 = false; + 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..b8cead65 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadInfo.java @@ -0,0 +1,185 @@ +/* + * 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 int method; + public String entity; + public boolean noIntegrity; + public String hint; + public String filename; + public boolean otaUpdate; + public String mimetype; + public int destination; + public boolean noSystem; + public int visibility; + public int control; + public int status; + public int numFailed; + 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, int method, String entity, boolean noIntegrity, + String hint, String filename, boolean otaUpdate, + String mimetype, int destination, boolean noSystem, int visibility, + int control, int status, int numFailed, 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.method = method; + this.entity = entity; + this.noIntegrity = noIntegrity; + this.hint = hint; + this.filename = filename; + this.otaUpdate = otaUpdate; + this.mimetype = mimetype; + this.destination = destination; + this.noSystem = noSystem; + this.visibility = visibility; + this.control = control; + this.status = status; + this.numFailed = numFailed; + 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() { + return lastMod + Constants.RETRY_FIRST_DELAY * 1000 * (1 << (numFailed - 1)); + } + + /** + * Returns whether this download should be started at the time when + * it's first inserted in the database. + */ + public boolean isReadyToStart(long now) { + 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 should be restarted at the time when + * it was already known by the download manager + */ + public boolean isReadyToRestart(long now) { + 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; + } +} diff --git a/src/com/android/providers/downloads/DownloadNotification.java b/src/com/android/providers/downloads/DownloadNotification.java new file mode 100644 index 00000000..38cd84f2 --- /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.FILENAME + }, + 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.FILENAME, + 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..c85c94a9 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadProvider.java @@ -0,0 +1,532 @@ +/* + * 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.Cursor; +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.BaseColumns; +import android.provider.Downloads; +import android.util.Config; +import android.util.Log; + +import java.io.FileNotFoundException; + +/** + * Allows application to interact with the download manager. + */ +public final class DownloadProvider extends ContentProvider { + + /** Tag used in logging */ + private static final String TAG = Constants.TAG; + + /** Database filename */ + private static final String DB_NAME = "downloads.db"; + /** Current database vesion */ + private static final int DB_VERSION = 31; + /** 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); + } + + /** 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, final int oldV, final int newV) { + Log.i(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 + "(" + + BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Downloads.URI + " TEXT, " + + Downloads.METHOD + " INTEGER, " + + Downloads.ENTITY + " TEXT, " + + Downloads.NO_INTEGRITY + " BOOLEAN, " + + Downloads.FILENAME_HINT + " TEXT, " + + Downloads.OTA_UPDATE + " BOOLEAN, " + + Downloads.FILENAME + " TEXT, " + + Downloads.MIMETYPE + " TEXT, " + + Downloads.DESTINATION + " INTEGER, " + + Downloads.NO_SYSTEM_FILES + " BOOLEAN, " + + Downloads.VISIBILITY + " INTEGER, " + + Downloads.CONTROL + " INTEGER, " + + Downloads.STATUS + " INTEGER, " + + Downloads.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, " + + Downloads.ETAG + " TEXT, " + + Downloads.UID + " INTEGER, " + + Downloads.OTHER_UID + " INTEGER, " + + Downloads.TITLE + " TEXT, " + + Downloads.DESCRIPTION + " TEXT, " + + Downloads.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); + } + + boolean hasUID = values.containsKey(Downloads.UID); + if (hasUID && Binder.getCallingUid() != 0) { + values.remove(Downloads.UID); + hasUID = false; + } + if (!hasUID) { + values.put(Downloads.UID, Binder.getCallingUid()); + } + if (Constants.LOGVV) { + Log.v(TAG, "initiating download with UID " + Binder.getCallingUid()); + if (values.containsKey(Downloads.OTHER_UID)) { + Log.v(TAG, "other UID " + values.getAsInteger(Downloads.OTHER_UID)); + } + } + + if (values.containsKey(Downloads.LAST_MODIFICATION)) { + values.remove(Downloads.LAST_MODIFICATION); + } + values.put(Downloads.LAST_MODIFICATION, System.currentTimeMillis()); + + if (values.containsKey(Downloads.STATUS)) { + values.remove(Downloads.STATUS); + } + values.put(Downloads.STATUS, Downloads.STATUS_PENDING); + + if (values.containsKey(Downloads.OTA_UPDATE) + && getContext().checkCallingPermission(Constants.OTA_UPDATE_PERMISSION) + != PackageManager.PERMISSION_GRANTED) { + values.remove(Downloads.OTA_UPDATE); + } + + Context context = getContext(); + context.startService(new Intent(context, DownloadService.class)); + + long rowID = db.insert(DB_TABLE, null, values); + + 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(TAG, "couldn't insert into downloads database"); + } + } + + return ret; + } + + /** + * Starts a database query + */ + @Override + public Cursor query(final Uri uri, final String[] projection, + final String selection, final String[] selectionArgs, + final String sort) { + 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(BaseColumns._ID + "="); + qb.appendWhere(uri.getPathSegments().get(1)); + emptyWhere = false; + break; + } + default: { + if (Constants.LOGV) { + Log.v(TAG, "querying unknown URI: " + uri); + } + throw new IllegalArgumentException("Unknown URI: " + uri); + } + } + + if (Binder.getCallingPid() != Process.myPid() + && Binder.getCallingUid() != 0 + && getContext().checkCallingPermission(Constants.UI_PERMISSION) + != PackageManager.PERMISSION_GRANTED) { + if (!emptyWhere) { + qb.appendWhere(" AND "); + } + qb.appendWhere("( " + Downloads.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"); + emptyWhere = false; + } + + 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(TAG, sb.toString()); + } + + Cursor ret = qb.query(db, projection, selection, selectionArgs, + null, null, sort); + + 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(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) { + SQLiteDatabase db = mOpenHelper.getWritableDatabase(); + + int count; + long rowId = 0; + if (values.containsKey(Downloads.UID)) { + values.remove(Downloads.UID); + } + 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 + && getContext().checkCallingPermission(Constants.UI_PERMISSION) + != PackageManager.PERMISSION_GRANTED) { + myWhere += " AND ( " + Downloads.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"; + } + count = db.update(DB_TABLE, values, myWhere, whereArgs); + break; + } + default: { + if (Config.LOGD) { + Log.d(TAG, "updating unknown/invalid URI: " + uri); + } + throw new UnsupportedOperationException("Cannot update URI: " + uri); + } + } + getContext().getContentResolver().notifyChange(uri, null); + return count; + } + + /** + * Deletes a row in the database + */ + @Override + public int delete(final Uri uri, final String where, + final String[] whereArgs) { + 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 + && getContext().checkCallingPermission(Constants.UI_PERMISSION) + != PackageManager.PERMISSION_GRANTED) { + myWhere += " AND ( " + Downloads.UID + "=" + Binder.getCallingUid() + " OR " + + Downloads.OTHER_UID + "=" + Binder.getCallingUid() + " )"; + } + count = db.delete(DB_TABLE, myWhere, whereArgs); + break; + } + default: { + if (Config.LOGD) { + Log.d(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(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(TAG, "null cursor in openFile"); + } else { + if (!cursor.moveToFirst()) { + Log.v(TAG, "empty cursor in openFile"); + } else { + do { + Log.v(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(TAG, "null cursor in openFile"); + } else { + if (!cursor.moveToFirst()) { + Log.v(TAG, "empty cursor in openFile"); + } else { + String filename = cursor.getString(0); + Log.v(TAG, "filename in openFile: " + filename); + if (new java.io.File(filename).isFile()) { + Log.v(TAG, "file exists in openFile"); + } + } + cursor.close(); + } + } + ParcelFileDescriptor ret = openFileHelper(uri, mode); + if (ret == null) { + if (Config.LOGD) { + Log.d(TAG, "couldn't open file"); + } + } else { + ContentValues values = new ContentValues(); + values.put(Downloads.LAST_MODIFICATION, System.currentTimeMillis()); + update(uri, values, null, null); + } + return ret; + } + +} diff --git a/src/com/android/providers/downloads/DownloadReceiver.java b/src/com/android/providers/downloads/DownloadReceiver.java new file mode 100644 index 00000000..e5bc4e1f --- /dev/null +++ b/src/com/android/providers/downloads/DownloadReceiver.java @@ -0,0 +1,167 @@ +/* + * 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.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 { + + /** Tag used for debugging/logging */ + public static final String TAG = Constants.TAG; + + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { + if (Constants.LOGVV) { + Log.v(TAG, "Receiver onBoot"); + } + context.startService(new Intent(context, DownloadService.class)); + } else if (intent.getAction().equals(ConnectivityManager.CONNECTIVITY_ACTION)) { + if (Constants.LOGVV) { + Log.v(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(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) { + boolean mustCommit = false; + 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) { + cursor.updateInt(visibilityColumn, Downloads.VISIBILITY_VISIBLE); + mustCommit = true; + } + + if (intent.getAction().equals(Constants.ACTION_OPEN)) { + int filenameColumn = cursor.getColumnIndexOrThrow(Downloads.FILENAME); + 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); + } + } + } + if (mustCommit) { + if (!cursor.commitUpdates()) { + Log.e(Constants.TAG, "commitUpdate failed in onReceive/OPEN-LIST"); + } + } + 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) { + cursor.updateInt(visibilityColumn, Downloads.VISIBILITY_VISIBLE); + if (!cursor.commitUpdates()) { + Log.e(Constants.TAG, "commitUpdate failed in onReceive/HIDE"); + } + } + } + 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..0d3650c0 --- /dev/null +++ b/src/com/android/providers/downloads/DownloadService.java @@ -0,0 +1,859 @@ +/* + * 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.BaseColumns; +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 ------------ */ + + /** Tag used for debugging/logging */ + private static final String TAG = Constants.TAG; + + /* ------------ 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(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(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(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(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(TAG, "Service onStart"); + } + + updateFromProvider(); + } + + /** + * Cleans up when the service is destroyed + */ + public void onDestroy() { + getContentResolver().unregisterContentObserver(mObserver); + if (Constants.LOGVV) { + Log.v(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); + long now = System.currentTimeMillis(); + + Cursor cursor = getContentResolver().query(Downloads.CONTENT_URI, + null, null, null, BaseColumns._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(BaseColumns._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(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, now); + if (Constants.LOGVV) { + Log.v(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(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, 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(TAG, "Array update: appending " + id + " @ " + arrayPos); + } + insertDownload(cursor, arrayPos, networkAvailable, 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(); + } + + if (!cursor.commitUpdates()) { + Log.e(Constants.TAG, "commitUpdates failed in updateFromProvider"); + } + 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.FILENAME }, 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(TAG, "null cursor in trimDatabase"); + return; + } + if (cursor.moveToFirst()) { + while (cursor.getCount() > Constants.MAX_DOWNLOADS) { + cursor.deleteRow(); + } + } + 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, long now) { + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int failedColumn = cursor.getColumnIndexOrThrow(Downloads.FAILED_CONNECTIONS); + DownloadInfo info = new DownloadInfo( + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads._ID)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.URI)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.METHOD)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.ENTITY)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.NO_INTEGRITY)) == 1, + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.FILENAME_HINT)), + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.FILENAME)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.OTA_UPDATE)) == 1, + cursor.getString(cursor.getColumnIndexOrThrow(Downloads.MIMETYPE)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.DESTINATION)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.NO_SYSTEM_FILES)) == 1, + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.VISIBILITY)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.CONTROL)), + cursor.getInt(statusColumn), + cursor.getInt(failedColumn), + 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(Downloads.ETAG)), + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.MEDIA_SCANNED)) == 1); + + if (Constants.LOGVV) { + Log.v(TAG, "Service adding new entry"); + Log.v(TAG, "ID : " + info.id); + Log.v(TAG, "URI : " + ((info.uri != null) ? "yes" : "no")); + Log.v(TAG, "METHOD : " + info.method); + Log.v(TAG, "ENTITY : " + ((info.entity != null) ? "yes" : "no")); + Log.v(TAG, "NO_INTEG: " + info.noIntegrity); + Log.v(TAG, "HINT : " + info.hint); + Log.v(TAG, "FILENAME: " + info.filename); + Log.v(TAG, "SYSIMAGE: " + info.otaUpdate); + Log.v(TAG, "MIMETYPE: " + info.mimetype); + Log.v(TAG, "DESTINAT: " + info.destination); + Log.v(TAG, "NO_SYSTE: " + info.noSystem); + Log.v(TAG, "VISIBILI: " + info.visibility); + Log.v(TAG, "CONTROL : " + info.control); + Log.v(TAG, "STATUS : " + info.status); + Log.v(TAG, "FAILED_C: " + info.numFailed); + Log.v(TAG, "LAST_MOD: " + info.lastMod); + Log.v(TAG, "PACKAGE : " + info.pckg); + Log.v(TAG, "CLASS : " + info.clazz); + Log.v(TAG, "COOKIES : " + ((info.cookies != null) ? "yes" : "no")); + Log.v(TAG, "AGENT : " + info.userAgent); + Log.v(TAG, "REFERER : " + ((info.referer != null) ? "yes" : "no")); + Log.v(TAG, "TOTAL : " + info.totalBytes); + Log.v(TAG, "CURRENT : " + info.currentBytes); + Log.v(TAG, "ETAG : " + info.etag); + Log.v(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); + List list = getPackageManager().queryIntentActivities(mimetypeIntent, + PackageManager.MATCH_DEFAULT_ONLY); + //Log.i(TAG, "*** QUERY " + mimetypeIntent + ": " + list); + + if (list.size() == 0 + || (info.noSystem && info.mimetype.equalsIgnoreCase(Constants.MIMETYPE_APK))) { + if (Config.LOGD) { + Log.d(Constants.TAG, "no application to handle MIME type " + info.mimetype); + } + info.status = Downloads.STATUS_NOT_ACCEPTABLE; + cursor.updateInt(statusColumn, Downloads.STATUS_NOT_ACCEPTABLE); + + Uri uri = Uri.parse(Downloads.CONTENT_URI + "/" + info.id); + Intent intent = new Intent(Downloads.DOWNLOAD_COMPLETED_ACTION); + intent.setData(uri); + sendBroadcast(intent, "android.permission.ACCESS_DOWNLOAD_DATA"); + info.sendIntentIfRequested(uri, this); + return; + } + } + + if (networkAvailable) { + if (info.isReadyToStart(now)) { + if (Constants.LOGV) { + Log.v(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; + cursor.updateInt(statusColumn, Downloads.STATUS_RUNNING_PAUSED); + } + } + } + + /** + * Updates the local copy of the info about a download. + */ + private void updateDownload(Cursor cursor, int arrayPos, boolean networkAvailable, long now) { + DownloadInfo info = (DownloadInfo) mDownloads.get(arrayPos); + int statusColumn = cursor.getColumnIndexOrThrow(Downloads.STATUS); + int failedColumn = cursor.getColumnIndexOrThrow(Downloads.FAILED_CONNECTIONS); + info.id = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads._ID)); + info.uri = stringFromCursor(info.uri, cursor, Downloads.URI); + info.method = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.METHOD)); + info.entity = stringFromCursor(info.entity, cursor, Downloads.ENTITY); + 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.FILENAME); + info.otaUpdate = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.OTA_UPDATE)) == 1; + info.mimetype = stringFromCursor(info.mimetype, cursor, Downloads.MIMETYPE); + info.destination = cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.DESTINATION)); + info.noSystem = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.NO_SYSTEM_FILES)) == 1; + 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; + 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); + 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, Downloads.ETAG); + info.mediaScanned = + cursor.getInt(cursor.getColumnIndexOrThrow(Downloads.MEDIA_SCANNED)) == 1; + + if (networkAvailable) { + if (info.isReadyToRestart(now)) { + if (Constants.LOGV) { + Log.v(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(TAG, "Scanning file " + info.filename); + } + mMediaScannerService.scanFile(info.filename, info.mimetype); + if (cursor != null) { + cursor.updateInt(cursor.getColumnIndexOrThrow(Downloads.MEDIA_SCANNED), 1); + } + return true; + } catch (RemoteException e) { + if (Config.LOGD) { + Log.d(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..66417b3e --- /dev/null +++ b/src/com/android/providers/downloads/DownloadThread.java @@ -0,0 +1,643 @@ +/* + * 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.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; + +/** + * Runs an actual download + */ +public class DownloadThread extends Thread { + + /** Tag used for debugging/logging */ + private static final String TAG = Constants.TAG; + + 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; + 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, TAG); + wakeLock.acquire(); + + if (mInfo.filename != null) { + // We're resuming a download that got interrupted + File f = new File(mInfo.filename); + if (f.exists()) { + long fileLength = f.length(); + if (fileLength == 0) { + // The download hadn't actually started, we can restart from scratch + f.delete(); + } else if (mInfo.etag == null && !mInfo.noIntegrity) { + // Tough luck, that's not a resumable download + if (Config.LOGD) { + Log.d(TAG, "can't resume interrupted non-resumable download"); + } + f.delete(); + finalStatus = Downloads.STATUS_PRECONDITION_FAILED; + notifyDownloadCompleted( + finalStatus, false, false, mInfo.filename, mInfo.mimetype); + return; + } else { + // All right, we'll be able to resume this download + filename = mInfo.filename; + 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. + HttpUriRequest requestU; + AbortableHttpRequest requestA; + if (mInfo.method == Downloads.METHOD_POST) { + HttpPost request = new HttpPost(mInfo.uri); + if (mInfo.entity != null) { + try { + request.setEntity(new StringEntity(mInfo.entity)); + } catch (UnsupportedEncodingException ex) { + if (Config.LOGD) { + Log.d(TAG, "unsupported encoding for POST entity : " + ex); + } + finalStatus = Downloads.STATUS_BAD_REQUEST; + break http_request_loop; + } + } + requestU = request; + requestA = request; + } else { + HttpGet request = new HttpGet(mInfo.uri); + requestU = request; + requestA = request; + } + + if (Constants.LOGV) { + Log.v(TAG, "initiating download for " + mInfo.uri); + } + + if (mInfo.cookies != null) { + requestU.addHeader("Cookie", mInfo.cookies); + } + if (mInfo.referer != null) { + requestU.addHeader("Referer", mInfo.referer); + } + if (continuingDownload) { + if (headerETag != null) { + requestU.addHeader("If-Match", headerETag); + } + requestU.addHeader("Range", "bytes=" + bytesSoFar + "-"); + } + + HttpResponse response; + try { + response = client.execute(requestU); + } catch (IllegalArgumentException ex) { + if (Constants.LOGV) { + Log.d(TAG, "Arg exception trying to execute request for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(TAG, "Arg exception trying to execute request for " + mInfo.id + + " : " + ex); + } + finalStatus = Downloads.STATUS_BAD_REQUEST; + requestA.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(TAG, "IOException trying to execute request for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(TAG, "IOException trying to execute request for " + mInfo.id + + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + requestA.abort(); + break http_request_loop; + } + + int statusCode = response.getStatusLine().getStatusCode(); + if ((!continuingDownload && statusCode != Downloads.STATUS_SUCCESS) + || (continuingDownload && statusCode != 206)) { + if (Constants.LOGV) { + Log.d(TAG, "http error " + statusCode + " for " + mInfo.uri); + } else if (Config.LOGD) { + Log.d(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; + } + requestA.abort(); + break http_request_loop; + } else { + // Handles the response, saves the file + if (Constants.LOGV) { + Log.v(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(TAG, "ignoring content-length because of xfer-encoding"); + } + } + if (Constants.LOGVV) { + Log.v(TAG, "Accept-Ranges: " + headerAcceptRanges); + Log.v(TAG, "Content-Disposition: " + headerContentDisposition); + Log.v(TAG, "Content-Length: " + headerContentLength); + Log.v(TAG, "Content-Location: " + headerContentLocation); + Log.v(TAG, "Content-Type: " + mimeType); + Log.v(TAG, "ETag: " + headerETag); + Log.v(TAG, "Transfer-Encoding: " + headerTransferEncoding); + } + + if (!mInfo.noIntegrity && headerContentLength == null && + (headerTransferEncoding == null + || !headerTransferEncoding.equalsIgnoreCase("chunked")) + ) { + if (Config.LOGD) { + Log.d(TAG, "can't know size of download, giving up"); + } + finalStatus = Downloads.STATUS_LENGTH_REQUIRED; + requestA.abort(); + break http_request_loop; + } + + DownloadFileInfo fileInfo = Helpers.generateSaveFile( + mContext, + mInfo.uri, + mInfo.hint, + headerContentDisposition, + headerContentLocation, + mimeType, + mInfo.destination, + mInfo.otaUpdate, + mInfo.noSystem, + (headerContentLength != null) ? + Integer.parseInt(headerContentLength) : 0); + if (fileInfo.filename == null) { + finalStatus = fileInfo.status; + requestA.abort(); + break http_request_loop; + } + filename = fileInfo.filename; + stream = fileInfo.stream; + if (Constants.LOGV) { + Log.v(TAG, "writing " + mInfo.uri + " to " + filename); + } + + ContentValues values = new ContentValues(); + values.put(Downloads.FILENAME, filename); + if (headerETag != null) { + values.put(Downloads.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(TAG, "IOException getting entity for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(TAG, "IOException getting entity for download " + mInfo.id + + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + requestA.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(TAG, "download IOException for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(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(TAG, "download IOException for " + mInfo.uri + + " : " + ex); + } else if (Config.LOGD) { + Log.d(TAG, "download IOException for download " + mInfo.id + + " : " + ex); + } + finalStatus = Downloads.STATUS_HTTP_DATA_ERROR; + } + requestA.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 (Constants.LOGV) { + Log.d(TAG, "mismatched content length " + mInfo.uri); + } else if (Config.LOGD) { + Log.d(TAG, "mismatched content length for " + mInfo.id); + } + finalStatus = Downloads.STATUS_LENGTH_REQUIRED; + 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(TAG, "downloaded " + bytesSoFar + " for " + mInfo.uri); + } + if (mInfo.status == Downloads.STATUS_CANCELED) { + if (Constants.LOGV) { + Log.d(TAG, "canceled " + mInfo.uri); + } else if (Config.LOGD) { + // Log.d(TAG, "canceled id " + mInfo.id); + } + finalStatus = Downloads.STATUS_CANCELED; + break http_request_loop; + } + } + if (Constants.LOGV) { + Log.v(TAG, "download completed for " + mInfo.uri); + } + finalStatus = Downloads.STATUS_SUCCESS; + } + break; + } + } catch (FileNotFoundException ex) { + if (Config.LOGD) { + Log.d(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(TAG, "Exception for " + mInfo.uri + " : " + ex); + } else if (Config.LOGD) { + Log.d(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(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, gotData, filename, mimeType); + } + } + + /** + * Stores information about the completed download, and notifies the initiating application. + */ + private void notifyDownloadCompleted( + int status, boolean countRetry, boolean gotData, String filename, String mimeType) { + notifyThroughDatabase(status, countRetry, gotData, filename, mimeType); + if (Downloads.isStatusCompleted(status)) { + notifyThroughIntent(); + } + } + + private void notifyThroughDatabase( + int status, boolean countRetry, boolean gotData, String filename, String mimeType) { + // Updates database when the download completes. + Cursor cursor = null; + + String projection[] = {}; + cursor = mContext.getContentResolver().query(Downloads.CONTENT_URI, + projection, Downloads._ID + "=" + mInfo.id, null, null); + + if (cursor != null) { + // Looping makes the code more solid in case there are 2 entries with the same id + while (cursor.moveToNext()) { + cursor.updateInt(cursor.getColumnIndexOrThrow(Downloads.STATUS), status); + cursor.updateString(cursor.getColumnIndexOrThrow(Downloads.FILENAME), filename); + cursor.updateString(cursor.getColumnIndexOrThrow(Downloads.MIMETYPE), mimeType); + cursor.updateLong(cursor.getColumnIndexOrThrow(Downloads.LAST_MODIFICATION), + System.currentTimeMillis()); + if (!countRetry) { + // if there's no reason to get delayed retry, clear this field + cursor.updateInt(cursor.getColumnIndexOrThrow(Downloads.FAILED_CONNECTIONS), 0); + } else if (gotData) { + // if there's a reason to get a delayed retry but we got some data in this + // try, reset the retry count. + cursor.updateInt(cursor.getColumnIndexOrThrow(Downloads.FAILED_CONNECTIONS), 1); + } else { + // should get a retry and didn't make any progress this time - increment count + cursor.updateInt(cursor.getColumnIndexOrThrow(Downloads.FAILED_CONNECTIONS), + mInfo.numFailed + 1); + } + } + cursor.commitUpdates(); + cursor.close(); + } + } + + /** + * 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); + Intent intent = new Intent(Downloads.DOWNLOAD_COMPLETED_ACTION); + intent.setData(uri); + mContext.sendBroadcast(intent, "android.permission.ACCESS_DOWNLOAD_DATA"); + 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..f966a7f5 --- /dev/null +++ b/src/com/android/providers/downloads/Helpers.java @@ -0,0 +1,510 @@ +/* + * 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.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.provider.Downloads; +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; + +/** + * Some helper functions for the download manager + */ +public class Helpers { + /** Tag used for debugging/logging */ + private static final String TAG = Constants.TAG; + + /** 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, + boolean otaUpdate, + boolean noSystem, + 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(TAG, "external download with no mime type not allowed"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_NOT_ACCEPTABLE); + } + if (noSystem && mimeType.equalsIgnoreCase(Constants.MIMETYPE_APK)) { + if (Config.LOGD) { + Log.d(TAG, "system files not allowed by initiating application"); + } + 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); + List list = pm.queryIntentActivities(intent, + PackageManager.MATCH_DEFAULT_ONLY); + //Log.i(TAG, "*** FILENAME QUERY " + intent + ": " + list); + + if (list.size() == 0) { + 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, otaUpdate); + + // 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, otaUpdate, 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 + || 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(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 (destination == Downloads.DESTINATION_DATA_CACHE) { + base = context.getCacheDir(); + if (!base.isDirectory() && !base.mkdir()) { + if (Config.LOGD) { + Log.d(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 (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(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(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(TAG, "download aborted - not enough free space"); + } + return new DownloadFileInfo(null, null, Downloads.STATUS_FILE_ERROR); + } + + } + + boolean otaFilename = Constants.OTA_UPDATE_FILENAME.equalsIgnoreCase(filename + extension); + 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(TAG, "target file: " + filename + extension); + } + + String fullFilename = chooseUniqueFilename( + destination, otaUpdate, filename, extension, otaFilename, 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, boolean otaUpdate) { + String filename = null; + + // Before we even start, special-case the OTA updates + if (destination == Downloads.DESTINATION_CACHE_PARTITION && otaUpdate) { + filename = Constants.OTA_UPDATE_FILENAME; + } + + // 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(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(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(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(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(TAG, "using default filename"); + } + filename = Constants.DEFAULT_DL_FILENAME; + } + 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(TAG, "adding extension from type"); + } + extension = "." + extension; + } else { + if (Constants.LOGVV) { + Log.v(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(TAG, "adding default html extension"); + } + extension = Constants.DEFAULT_DL_HTML_EXTENSION; + } else if (useDefaults) { + if (Constants.LOGVV) { + Log.v(TAG, "adding default text extension"); + } + extension = Constants.DEFAULT_DL_TEXT_EXTENSION; + } + } else if (useDefaults) { + if (Constants.LOGVV) { + Log.v(TAG, "adding default binary extension"); + } + extension = Constants.DEFAULT_DL_BINARY_EXTENSION; + } + } + return extension; + } + + private static String chooseExtensionFromFilename(String mimeType, int destination, + boolean otaUpdate, String filename, int dotIndex) { + String extension = null; + if (mimeType != null + && !(destination == Downloads.DESTINATION_CACHE_PARTITION && otaUpdate)) { + // 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(TAG, "substituting extension from type"); + } + } else { + if (Constants.LOGVV) { + Log.v(TAG, "couldn't find extension for " + mimeType); + } + } + } + } + if (extension == null) { + if (Constants.LOGVV) { + Log.v(TAG, "keeping extension"); + } + extension = filename.substring(dotIndex); + } + return extension; + } + + private static String chooseUniqueFilename(int destination, boolean otaUpdate, String filename, + String extension, boolean otaFilename, boolean recoveryDir) { + String fullFilename = filename + extension; + if (!new File(fullFilename).exists() + && (!recoveryDir || + (destination != Downloads.DESTINATION_CACHE_PARTITION && + destination != Downloads.DESTINATION_CACHE_PARTITION_PURGEABLE)) + && (!otaFilename || + (otaUpdate && destination == Downloads.DESTINATION_CACHE_PARTITION))) { + return fullFilename; + } else if (!(otaUpdate && destination == Downloads.DESTINATION_CACHE_PARTITION)) { + 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; + Random random = new Random(); + 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(TAG, "file with sequence number " + sequence + " exists"); + } + sequence += random.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.FILENAME))); + if (Constants.LOGVV) { + Log.v(Constants.TAG, "purging " + file.getAbsolutePath() + " for " + + file.length() + " bytes"); + } + totalFreed += file.length(); + file.delete(); + cursor.deleteRow(); // This moves the cursor to the next entry, + // no need to call next() + } + } 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.getActiveNetworkInfo(); + if (info != null) { + if (info.getState() == NetworkInfo.State.CONNECTED) { + if (Constants.LOGVV) { + Log.v(TAG, "network is available"); + } + return true; + } + } + } + if (Constants.LOGVV) { + Log.v(TAG, "network is not available"); + } + return false; + } + +} -- cgit v1.2.3