/* * 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.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.os.Parcelable; import android.provider.BaseColumns; import android.provider.Downloads; import android.util.Log; import android.util.SparseBooleanArray; import android.view.ActionMode; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.widget.AbsListView.MultiChoiceModeListener; 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.Constants; import com.android.providers.downloads.OpenHelper; import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.Set; /** * View showing a list of all downloads the Download Manager knows about. */ public class DownloadList extends Activity { static final String LOG_TAG = "DownloadList"; private ExpandableListView mDateOrderedListView; private ListView mSizeOrderedListView; private View mEmptyView; private DownloadManager mDownloadManager; private Cursor mDateSortedCursor; private DateSortedDownloadAdapter mDateSortedAdapter; private Cursor mSizeSortedCursor; private DownloadAdapter mSizeSortedAdapter; private ActionMode mActionMode; 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; // TODO this shouldn't be necessary private final Map mSelectedIds = new HashMap(); private static class SelectionObjAttrs { private String mFileName; private String mMimeType; SelectionObjAttrs(String fileName, String mimeType) { mFileName = fileName; mMimeType = mimeType; } String getFileName() { return mFileName; } String getMimeType() { return mMimeType; } } private ListView mCurrentView; private Cursor mCurrentCursor; private boolean mCurrentViewIsExpandableListView = false; private boolean mIsSortedBySize = false; /** * 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; String mSelectedCountFormat; private Button mSortOption; 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() { // ignore change notification if there are selections if (mSelectedIds.size() > 0) { return; } // may need to switch to or from the empty view chooseListToShow(); ensureSomeGroupIsExpanded(); } } @Override public void onCreate(Bundle icicle) { super.onCreate(icicle); setFinishOnTouchOutside(true); setupViews(); mDownloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); mDownloadManager.setAccessAllDownloads(true); DownloadManager.Query baseQuery = new DownloadManager.Query() .setOnlyIncludeVisibleInDownloadsUi(true); //TODO don't do both queries - do them as needed 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); mDateSortedAdapter = new DateSortedDownloadAdapter(this, mDateSortedCursor); mDateOrderedListView.setAdapter(mDateSortedAdapter); mSizeSortedAdapter = new DownloadAdapter(this, mSizeSortedCursor); mSizeOrderedListView.setAdapter(mSizeSortedAdapter); ensureSomeGroupIsExpanded(); } // did the caller want to display the data sorted by size? Bundle extras = getIntent().getExtras(); if (extras != null && extras.getBoolean(DownloadManager.INTENT_EXTRAS_SORT_BY_SIZE, false)) { mIsSortedBySize = true; } mSortOption = (Button) findViewById(R.id.sort_button); mSortOption.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { // flip the view mIsSortedBySize = !mIsSortedBySize; // clear all selections mSelectedIds.clear(); chooseListToShow(); } }); chooseListToShow(); mSelectedCountFormat = getString(R.string.selected_count); } /** * 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); ModeCallback modeCallback = new ModeCallback(this); //TODO don't create both views. create only the one needed. mDateOrderedListView = (ExpandableListView) findViewById(R.id.date_ordered_list); mDateOrderedListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); mDateOrderedListView.setMultiChoiceModeListener(modeCallback); mDateOrderedListView.setOnChildClickListener(new OnChildClickListener() { // called when a child is clicked on (this is NOT the checkbox click) @Override public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) { if (!(v instanceof DownloadItem)) { // can this even happen? return false; } if (mSelectedIds.size() > 0) { ((DownloadItem)v).setChecked(true); } else { mDateSortedAdapter.moveCursorToChildPosition(groupPosition, childPosition); handleItemClick(mDateSortedCursor); } return true; } }); mSizeOrderedListView = (ListView) findViewById(R.id.size_ordered_list); mSizeOrderedListView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); mSizeOrderedListView.setMultiChoiceModeListener(modeCallback); mSizeOrderedListView.setOnItemClickListener(new OnItemClickListener() { // handle a click from the size-sorted list. (this is NOT the checkbox click) @Override public void onItemClick(AdapterView parent, View view, int position, long id) { mSizeSortedCursor.moveToPosition(position); handleItemClick(mSizeSortedCursor); } }); mEmptyView = findViewById(R.id.empty); } private static class ModeCallback implements MultiChoiceModeListener { private final DownloadList mDownloadList; public ModeCallback(DownloadList downloadList) { mDownloadList = downloadList; } @Override public void onDestroyActionMode(ActionMode mode) { mDownloadList.mSelectedIds.clear(); mDownloadList.mActionMode = null; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return true; } @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { if (mDownloadList.haveCursors()) { final MenuInflater inflater = mDownloadList.getMenuInflater(); inflater.inflate(R.menu.download_menu, menu); } mDownloadList.mActionMode = mode; return true; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (mDownloadList.mSelectedIds.size() == 0) { // nothing selected. return true; } switch (item.getItemId()) { case R.id.delete_download: for (Long downloadId : mDownloadList.mSelectedIds.keySet()) { mDownloadList.deleteDownload(downloadId); } // uncheck all checked items ListView lv = mDownloadList.getCurrentView(); SparseBooleanArray checkedPositionList = lv.getCheckedItemPositions(); int checkedPositionListSize = checkedPositionList.size(); ArrayList sharedFiles = null; for (int i = 0; i < checkedPositionListSize; i++) { int position = checkedPositionList.keyAt(i); if (checkedPositionList.get(position, false)) { lv.setItemChecked(position, false); onItemCheckedStateChanged(mode, position, 0, false); } } mDownloadList.mSelectedIds.clear(); // update the subtitle onItemCheckedStateChanged(mode, 1, 0, false); break; case R.id.share_download: mDownloadList.shareDownloadedFiles(); break; } return true; } @Override public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { // ignore long clicks on groups if (mDownloadList.isCurrentViewExpandableListView()) { ExpandableListView ev = mDownloadList.getExpandableListView(); long pos = ev.getExpandableListPosition(position); if (checked && (ExpandableListView.getPackedPositionType(pos) == ExpandableListView.PACKED_POSITION_TYPE_GROUP)) { // ignore this click ev.setItemChecked(position, false); return; } } mDownloadList.setActionModeTitle(mode); } } void setActionModeTitle(ActionMode mode) { int numSelected = mSelectedIds.size(); if (numSelected > 0) { mode.setTitle(String.format(mSelectedCountFormat, numSelected, mCurrentCursor.getCount())); } else { mode.setTitle(""); } } 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); } } private static final String BUNDLE_SAVED_DOWNLOAD_IDS = "download_ids"; private static final String BUNDLE_SAVED_FILENAMES = "filenames"; private static final String BUNDLE_SAVED_MIMETYPES = "mimetypes"; @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putBoolean("isSortedBySize", mIsSortedBySize); int len = mSelectedIds.size(); if (len == 0) { return; } long[] selectedIds = new long[len]; String[] fileNames = new String[len]; String[] mimeTypes = new String[len]; int i = 0; for (long id : mSelectedIds.keySet()) { selectedIds[i] = id; SelectionObjAttrs obj = mSelectedIds.get(id); fileNames[i] = obj.getFileName(); mimeTypes[i] = obj.getMimeType(); i++; } outState.putLongArray(BUNDLE_SAVED_DOWNLOAD_IDS, selectedIds); outState.putStringArray(BUNDLE_SAVED_FILENAMES, fileNames); outState.putStringArray(BUNDLE_SAVED_MIMETYPES, mimeTypes); } @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); mIsSortedBySize = savedInstanceState.getBoolean("isSortedBySize"); mSelectedIds.clear(); long[] selectedIds = savedInstanceState.getLongArray(BUNDLE_SAVED_DOWNLOAD_IDS); String[] fileNames = savedInstanceState.getStringArray(BUNDLE_SAVED_FILENAMES); String[] mimeTypes = savedInstanceState.getStringArray(BUNDLE_SAVED_MIMETYPES); if (selectedIds != null && selectedIds.length > 0) { for (int i = 0; i < selectedIds.length; i++) { mSelectedIds.put(selectedIds[i], new SelectionObjAttrs(fileNames[i], mimeTypes[i])); } } chooseListToShow(); } /** * 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); mSortOption.setVisibility(View.GONE); } else { mEmptyView.setVisibility(View.GONE); mSortOption.setVisibility(View.VISIBLE); ListView lv = activeListView(); lv.setVisibility(View.VISIBLE); lv.invalidateViews(); // ensure checkboxes get updated } // restore the ActionMode title if there are selections if (mActionMode != null) { setActionModeTitle(mActionMode); } } ListView getCurrentView() { return mCurrentView; } ExpandableListView getExpandableListView() { return mDateOrderedListView; } boolean isCurrentViewExpandableListView() { return mCurrentViewIsExpandableListView; } private ListView activeListView() { if (mIsSortedBySize) { mCurrentCursor = mSizeSortedCursor; mCurrentView = mSizeOrderedListView; setTitle(R.string.download_title_sorted_by_size); mSortOption.setText(R.string.button_sort_by_date); mCurrentViewIsExpandableListView = false; } else { mCurrentCursor = mDateSortedCursor; mCurrentView = mDateOrderedListView; setTitle(R.string.download_title_sorted_by_date); mSortOption.setText(R.string.button_sort_by_size); mCurrentViewIsExpandableListView = true; } if (mActionMode != null) { mActionMode.finish(); } return mCurrentView; } /** * @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) { final 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 } final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BaseColumns._ID)); final Intent intent = OpenHelper.buildViewIntent(this, id); 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_queued_body) .setMessage(R.string.dialog_queued_body) .setPositiveButton(R.string.keep_queued_download, null) .setNegativeButton(R.string.remove_download, getDeleteClickHandler(id)) .setOnCancelListener(new DialogInterface.OnCancelListener() { /** * Called when a dialog for a pending download is canceled. */ @Override public void onCancel(DialogInterface dialog) { mQueuedDownloadId = null; mQueuedDialog = null; } }) .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) .setNegativeButton(R.string.delete_download, getDeleteClickHandler(downloadId)) .setPositiveButton(R.string.retry_download, getRestartClickHandler(downloadId)) .show(); } private void sendRunningDownloadClickedBroadcast(long id) { final Intent intent = new Intent(Constants.ACTION_LIST); intent.setPackage(Constants.PROVIDER_PACKAGE_NAME); intent.putExtra(DownloadManager.EXTRA_NOTIFICATION_CLICK_DOWNLOAD_IDS, new long[] { id }); sendBroadcast(intent); } // handle a click on one of the download item checkboxes public void onDownloadSelectionChanged(long downloadId, boolean isSelected, String fileName, String mimeType) { if (isSelected) { mSelectedIds.put(downloadId, new SelectionObjAttrs(fileName, mimeType)); } else { mSelectedIds.remove(downloadId); } } /** * Requery the database and update the UI. */ private void refresh() { mDateSortedCursor.requery(); mSizeSortedCursor.requery(); // Adapters get notification of changes and update automatically } /** * Delete a download from the Download Manager. */ private void deleteDownload(long downloadId) { // let DownloadService do the job of cleaning up the downloads db, mediaprovider db, // and removal of file from sdcard // TODO do the following in asynctask - not on main thread. mDownloadManager.markRowDeleted(downloadId); } public boolean isDownloadSelected(long id) { return mSelectedIds.containsKey(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.keySet().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; } /** * handle share menu button click when one more files are selected for sharing */ public boolean shareDownloadedFiles() { Intent intent = new Intent(); if (mSelectedIds.size() > 1) { intent.setAction(Intent.ACTION_SEND_MULTIPLE); ArrayList attachments = new ArrayList(); ArrayList mimeTypes = new ArrayList(); for (Map.Entry item : mSelectedIds.entrySet()) { final Uri uri = ContentUris.withAppendedId( Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, item.getKey()); final String mimeType = item.getValue().getMimeType(); attachments.add(uri); mimeTypes.add(mimeType); } intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments); intent.setType(findCommonMimeType(mimeTypes)); } else { // get the entry // since there is ONLY one entry in this, we can do the following for (Map.Entry item : mSelectedIds.entrySet()) { final Uri uri = ContentUris.withAppendedId( Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, item.getKey()); final String mimeType = item.getValue().getMimeType(); intent.setAction(Intent.ACTION_SEND); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.setType(mimeType); } } intent = Intent.createChooser(intent, getText(R.string.download_share_dialog)); startActivity(intent); return true; } private String findCommonMimeType(ArrayList mimeTypes) { // are all mimeypes the same? String str = findCommonString(mimeTypes); if (str != null) { return str; } // are all prefixes of the given mimetypes the same? ArrayList mimeTypePrefixes = new ArrayList(); for (String s : mimeTypes) { if (s != null) { mimeTypePrefixes.add(s.substring(0, s.indexOf('/'))); } } str = findCommonString(mimeTypePrefixes); if (str != null) { return str + "/*"; } // return generic mimetype return "*/*"; } private String findCommonString(Collection set) { String str = null; boolean found = true; for (String s : set) { if (str == null) { str = s; } else if (!str.equals(s)) { found = false; break; } } return (found) ? str : null; } }