/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.providers.downloads.ui; import android.app.Activity; import android.app.AlertDialog; import android.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.ContentUris; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnCancelListener; import android.content.Intent; import android.database.ContentObserver; import android.database.Cursor; import android.database.DataSetObserver; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.provider.Downloads; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.animation.AnimationUtils; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.Button; import android.widget.ExpandableListView; import android.widget.ExpandableListView.OnChildClickListener; import android.widget.ListView; import android.widget.Toast; import com.android.providers.downloads.ui.DownloadItem.DownloadSelectListener; import java.io.FileNotFoundException; import java.io.IOException; import java.util.HashSet; import java.util.Iterator; import java.util.Set; /** * View showing a list of all downloads the Download Manager knows about. */ public class DownloadList extends Activity implements OnChildClickListener, OnItemClickListener, DownloadSelectListener, OnClickListener, OnCancelListener { private static final String LOG_TAG = "DownloadList"; private ExpandableListView mDateOrderedListView; private ListView mSizeOrderedListView; private View mEmptyView; private ViewGroup mSelectionMenuView; private Button mSelectionDeleteButton; private Button mSelectionRestartButton; private DownloadManager mDownloadManager; private Cursor mDateSortedCursor; private DateSortedDownloadAdapter mDateSortedAdapter; private Cursor mSizeSortedCursor; private DownloadAdapter mSizeSortedAdapter; private MyContentObserver mContentObserver = new MyContentObserver(); private MyDataSetObserver mDataSetObserver = new MyDataSetObserver(); private int mStatusColumnId; private int mIdColumnId; private int mLocalUriColumnId; private int mMediaTypeColumnId; private int mReasonColumndId; private int mMediaProviderUriId; private boolean mIsSortedBySize = false; private Set mSelectedIds = new HashSet(); /** * We keep track of when a dialog is being displayed for a pending download, because if that * download starts running, we want to immediately hide the dialog. */ private Long mQueuedDownloadId = null; private AlertDialog mQueuedDialog; private class MyContentObserver extends ContentObserver { public MyContentObserver() { super(new Handler()); } @Override public void onChange(boolean selfChange) { handleDownloadsChanged(); } } private class MyDataSetObserver extends DataSetObserver { @Override public void onChanged() { // may need to switch to or from the empty view chooseListToShow(); ensureSomeGroupIsExpanded(); } } @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setupViews(); mDownloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); mDownloadManager.setAccessAllDownloads(true); DownloadManager.Query baseQuery = new DownloadManager.Query() .setOnlyIncludeVisibleInDownloadsUi(true); mDateSortedCursor = mDownloadManager.query(baseQuery); mSizeSortedCursor = mDownloadManager.query(baseQuery .orderBy(DownloadManager.COLUMN_TOTAL_SIZE_BYTES, DownloadManager.Query.ORDER_DESCENDING)); // only attach everything to the listbox if we can access the download database. Otherwise, // just show it empty if (haveCursors()) { startManagingCursor(mDateSortedCursor); startManagingCursor(mSizeSortedCursor); mStatusColumnId = mDateSortedCursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS); mIdColumnId = mDateSortedCursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ID); mLocalUriColumnId = mDateSortedCursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI); mMediaTypeColumnId = mDateSortedCursor.getColumnIndexOrThrow(DownloadManager.COLUMN_MEDIA_TYPE); mReasonColumndId = mDateSortedCursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON); mMediaProviderUriId = mDateSortedCursor.getColumnIndexOrThrow( DownloadManager.COLUMN_MEDIAPROVIDER_URI); mDateSortedAdapter = new DateSortedDownloadAdapter(this, mDateSortedCursor, this); mDateOrderedListView.setAdapter(mDateSortedAdapter); mSizeSortedAdapter = new DownloadAdapter(this, mSizeSortedCursor, this); mSizeOrderedListView.setAdapter(mSizeSortedAdapter); ensureSomeGroupIsExpanded(); } chooseListToShow(); } /** * If no group is expanded in the date-sorted list, expand the first one. */ private void ensureSomeGroupIsExpanded() { mDateOrderedListView.post(new Runnable() { public void run() { if (mDateSortedAdapter.getGroupCount() == 0) { return; } for (int group = 0; group < mDateSortedAdapter.getGroupCount(); group++) { if (mDateOrderedListView.isGroupExpanded(group)) { return; } } mDateOrderedListView.expandGroup(0); } }); } private void setupViews() { setContentView(R.layout.download_list); setTitle(getText(R.string.download_title)); mDateOrderedListView = (ExpandableListView) findViewById(R.id.date_ordered_list); mDateOrderedListView.setOnChildClickListener(this); mSizeOrderedListView = (ListView) findViewById(R.id.size_ordered_list); mSizeOrderedListView.setOnItemClickListener(this); mEmptyView = findViewById(R.id.empty); mSelectionMenuView = (ViewGroup) findViewById(R.id.selection_menu); mSelectionDeleteButton = (Button) findViewById(R.id.selection_delete); mSelectionDeleteButton.setOnClickListener(this); mSelectionRestartButton = (Button) findViewById(R.id.selection_retry); mSelectionRestartButton.setOnClickListener(this); ((Button) findViewById(R.id.deselect_all)).setOnClickListener(this); } private boolean haveCursors() { return mDateSortedCursor != null && mSizeSortedCursor != null; } @Override protected void onResume() { super.onResume(); if (haveCursors()) { mDateSortedCursor.registerContentObserver(mContentObserver); mDateSortedCursor.registerDataSetObserver(mDataSetObserver); refresh(); } } @Override protected void onPause() { super.onPause(); if (haveCursors()) { mDateSortedCursor.unregisterContentObserver(mContentObserver); mDateSortedCursor.unregisterDataSetObserver(mDataSetObserver); } } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean("isSortedBySize", mIsSortedBySize); outState.putLongArray("selection", getSelectionAsArray()); } private long[] getSelectionAsArray() { long[] selectedIds = new long[mSelectedIds.size()]; Iterator iterator = mSelectedIds.iterator(); for (int i = 0; i < selectedIds.length; i++) { selectedIds[i] = iterator.next(); } return selectedIds; } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); mIsSortedBySize = savedInstanceState.getBoolean("isSortedBySize"); mSelectedIds.clear(); for (long selectedId : savedInstanceState.getLongArray("selection")) { mSelectedIds.add(selectedId); } chooseListToShow(); showOrHideSelectionMenu(); } @Override public boolean onCreateOptionsMenu(Menu menu) { if (haveCursors()) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.download_menu, menu); } return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.download_menu_sort_by_size).setVisible(!mIsSortedBySize); menu.findItem(R.id.download_menu_sort_by_date).setVisible(mIsSortedBySize); return super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.download_menu_sort_by_size: mIsSortedBySize = true; chooseListToShow(); return true; case R.id.download_menu_sort_by_date: mIsSortedBySize = false; chooseListToShow(); return true; } return false; } /** * Show the correct ListView and hide the other, or hide both and show the empty view. */ private void chooseListToShow() { mDateOrderedListView.setVisibility(View.GONE); mSizeOrderedListView.setVisibility(View.GONE); if (mDateSortedCursor == null || mDateSortedCursor.getCount() == 0) { mEmptyView.setVisibility(View.VISIBLE); } else { mEmptyView.setVisibility(View.GONE); activeListView().setVisibility(View.VISIBLE); activeListView().invalidateViews(); // ensure checkboxes get updated } } /** * @return the ListView that should currently be visible. */ private ListView activeListView() { if (mIsSortedBySize) { return mSizeOrderedListView; } return mDateOrderedListView; } /** * @return an OnClickListener to delete the given downloadId from the Download Manager */ private DialogInterface.OnClickListener getDeleteClickHandler(final long downloadId) { return new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { deleteDownload(downloadId); } }; } /** * @return an OnClickListener to restart the given downloadId in the Download Manager */ private DialogInterface.OnClickListener getRestartClickHandler(final long downloadId) { return new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { mDownloadManager.restartDownload(downloadId); } }; } /** * Send an Intent to open the download currently pointed to by the given cursor. */ private void openCurrentDownload(Cursor cursor) { Uri localUri = Uri.parse(cursor.getString(mLocalUriColumnId)); try { getContentResolver().openFileDescriptor(localUri, "r").close(); } catch (FileNotFoundException exc) { Log.d(LOG_TAG, "Failed to open download " + cursor.getLong(mIdColumnId), exc); showFailedDialog(cursor.getLong(mIdColumnId), getString(R.string.dialog_file_missing_body)); return; } catch (IOException exc) { // close() failed, not a problem } Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(localUri, cursor.getString(mMediaTypeColumnId)); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); try { startActivity(intent); } catch (ActivityNotFoundException ex) { Toast.makeText(this, R.string.download_no_application_title, Toast.LENGTH_LONG).show(); } } private void handleItemClick(Cursor cursor) { long id = cursor.getInt(mIdColumnId); switch (cursor.getInt(mStatusColumnId)) { case DownloadManager.STATUS_PENDING: case DownloadManager.STATUS_RUNNING: sendRunningDownloadClickedBroadcast(id); break; case DownloadManager.STATUS_PAUSED: if (isPausedForWifi(cursor)) { mQueuedDownloadId = id; mQueuedDialog = new AlertDialog.Builder(this) .setTitle(R.string.dialog_title_not_available) .setMessage(R.string.dialog_queued_body) .setPositiveButton(R.string.keep_queued_download, null) .setNegativeButton(R.string.remove_download, getDeleteClickHandler(id)) .setOnCancelListener(this) .show(); } else { sendRunningDownloadClickedBroadcast(id); } break; case DownloadManager.STATUS_SUCCESSFUL: openCurrentDownload(cursor); break; case DownloadManager.STATUS_FAILED: showFailedDialog(id, getErrorMessage(cursor)); break; } } /** * @return the appropriate error message for the failed download pointed to by cursor */ private String getErrorMessage(Cursor cursor) { switch (cursor.getInt(mReasonColumndId)) { case DownloadManager.ERROR_FILE_ALREADY_EXISTS: if (isOnExternalStorage(cursor)) { return getString(R.string.dialog_file_already_exists); } else { // the download manager should always find a free filename for cache downloads, // so this indicates a strange internal error return getUnknownErrorMessage(); } case DownloadManager.ERROR_INSUFFICIENT_SPACE: if (isOnExternalStorage(cursor)) { return getString(R.string.dialog_insufficient_space_on_external); } else { return getString(R.string.dialog_insufficient_space_on_cache); } case DownloadManager.ERROR_DEVICE_NOT_FOUND: return getString(R.string.dialog_media_not_found); case DownloadManager.ERROR_CANNOT_RESUME: return getString(R.string.dialog_cannot_resume); default: return getUnknownErrorMessage(); } } private boolean isOnExternalStorage(Cursor cursor) { String localUriString = cursor.getString(mLocalUriColumnId); if (localUriString == null) { return false; } Uri localUri = Uri.parse(localUriString); if (!localUri.getScheme().equals("file")) { return false; } String path = localUri.getPath(); String externalRoot = Environment.getExternalStorageDirectory().getPath(); return path.startsWith(externalRoot); } private String getUnknownErrorMessage() { return getString(R.string.dialog_failed_body); } private void showFailedDialog(long downloadId, String dialogBody) { new AlertDialog.Builder(this) .setTitle(R.string.dialog_title_not_available) .setMessage(dialogBody) .setPositiveButton(R.string.remove_download, getDeleteClickHandler(downloadId)) .setNegativeButton(R.string.retry_download, getRestartClickHandler(downloadId)) .show(); } /** * TODO use constants/shared code? */ private void sendRunningDownloadClickedBroadcast(long id) { Intent intent = new Intent("android.intent.action.DOWNLOAD_LIST"); intent.setClassName("com.android.providers.downloads", "com.android.providers.downloads.DownloadReceiver"); intent.setData(ContentUris.withAppendedId(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, id)); intent.putExtra("multiple", false); sendBroadcast(intent); } // handle a click from the date-sorted list @Override public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) { mDateSortedAdapter.moveCursorToChildPosition(groupPosition, childPosition); handleItemClick(mDateSortedCursor); return true; } // handle a click from the size-sorted list @Override public void onItemClick(AdapterView parent, View view, int position, long id) { mSizeSortedCursor.moveToPosition(position); handleItemClick(mSizeSortedCursor); } // handle a click on one of the download item checkboxes @Override public void onDownloadSelectionChanged(long downloadId, boolean isSelected) { if (isSelected) { mSelectedIds.add(downloadId); } else { mSelectedIds.remove(downloadId); } showOrHideSelectionMenu(); } private void showOrHideSelectionMenu() { boolean shouldBeVisible = !mSelectedIds.isEmpty(); boolean isVisible = mSelectionMenuView.getVisibility() == View.VISIBLE; if (shouldBeVisible) { updateSelectionMenu(); if (!isVisible) { // show menu mSelectionMenuView.setVisibility(View.VISIBLE); mSelectionMenuView.startAnimation( AnimationUtils.loadAnimation(this, R.anim.footer_appear)); } } else if (!shouldBeVisible && isVisible) { // hide menu mSelectionMenuView.setVisibility(View.GONE); mSelectionMenuView.startAnimation( AnimationUtils.loadAnimation(this, R.anim.footer_disappear)); } } /** * Set up the contents of the selection menu based on the current selection. */ private void updateSelectionMenu() { mSelectionRestartButton.setVisibility(View.GONE); int deleteButtonStringId = R.string.delete_download; if (mSelectedIds.size() == 1) { Cursor cursor = mDownloadManager.query(new DownloadManager.Query() .setFilterById(mSelectedIds.iterator().next())); try { cursor.moveToFirst(); switch (cursor.getInt(mStatusColumnId)) { case DownloadManager.STATUS_FAILED: deleteButtonStringId = R.string.remove_download; mSelectionRestartButton.setVisibility(View.VISIBLE); break; case DownloadManager.STATUS_PENDING: deleteButtonStringId = R.string.remove_download; break; case DownloadManager.STATUS_PAUSED: case DownloadManager.STATUS_RUNNING: deleteButtonStringId = R.string.cancel_running_download; break; } } finally { cursor.close(); } } mSelectionDeleteButton.setText(deleteButtonStringId); } @Override public void onClick(View v) { switch (v.getId()) { case R.id.selection_delete: for (Long downloadId : mSelectedIds) { deleteDownload(downloadId); } clearSelection(); break; case R.id.selection_retry: for (Long downloadId : mSelectedIds) { mDownloadManager.restartDownload(downloadId); } clearSelection(); break; case R.id.deselect_all: clearSelection(); break; } } /** * Requery the database and update the UI. */ private void refresh() { mDateSortedCursor.requery(); mSizeSortedCursor.requery(); // Adapters get notification of changes and update automatically } private void clearSelection() { mSelectedIds.clear(); showOrHideSelectionMenu(); } /** * Delete a download from the Download Manager. */ private void deleteDownload(long downloadId) { if (moveToDownload(downloadId)) { int status = mDateSortedCursor.getInt(mStatusColumnId); boolean isComplete = status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED; String localUri = mDateSortedCursor.getString(mLocalUriColumnId); if (isComplete && localUri != null) { String path = Uri.parse(localUri).getPath(); if (path.startsWith(Environment.getExternalStorageDirectory().getPath())) { String mediaProviderUri = mDateSortedCursor.getString(mMediaProviderUriId); if (TextUtils.isEmpty(mediaProviderUri)) { // downloads database doesn't have the mediaprovider_uri. It means // this download occurred before mediaprovider_uri column existed // in downloads table. Since MediaProvider needs the mediaprovider_uri to // delete this download, just set the 'deleted' flag to 1 on this row // in the database. DownloadService, upon seeing this flag set to 1, will // re-scan the file and get the MediaProviderUri and then delete the file mDownloadManager.markRowDeleted(downloadId); return; } else { getContentResolver().delete(Uri.parse(mediaProviderUri), null, null); } } } } mDownloadManager.remove(downloadId); } @Override public boolean isDownloadSelected(long id) { return mSelectedIds.contains(id); } /** * Called when there's a change to the downloads database. */ void handleDownloadsChanged() { checkSelectionForDeletedEntries(); if (mQueuedDownloadId != null && moveToDownload(mQueuedDownloadId)) { if (mDateSortedCursor.getInt(mStatusColumnId) != DownloadManager.STATUS_PAUSED || !isPausedForWifi(mDateSortedCursor)) { mQueuedDialog.cancel(); } } } private boolean isPausedForWifi(Cursor cursor) { return cursor.getInt(mReasonColumndId) == DownloadManager.PAUSED_QUEUED_FOR_WIFI; } /** * Check if any of the selected downloads have been deleted from the downloads database, and * remove such downloads from the selection. */ private void checkSelectionForDeletedEntries() { // gather all existing IDs... Set allIds = new HashSet(); for (mDateSortedCursor.moveToFirst(); !mDateSortedCursor.isAfterLast(); mDateSortedCursor.moveToNext()) { allIds.add(mDateSortedCursor.getLong(mIdColumnId)); } // ...and check if any selected IDs are now missing for (Iterator iterator = mSelectedIds.iterator(); iterator.hasNext(); ) { if (!allIds.contains(iterator.next())) { iterator.remove(); } } } /** * Move {@link #mDateSortedCursor} to the download with the given ID. * @return true if the specified download ID was found; false otherwise */ private boolean moveToDownload(long downloadId) { for (mDateSortedCursor.moveToFirst(); !mDateSortedCursor.isAfterLast(); mDateSortedCursor.moveToNext()) { if (mDateSortedCursor.getLong(mIdColumnId) == downloadId) { return true; } } return false; } /** * Called when a dialog for a pending download is canceled. */ @Override public void onCancel(DialogInterface dialog) { mQueuedDownloadId = null; mQueuedDialog = null; } }