/* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui; import static com.android.documentsui.BaseActivity.State.ACTION_BROWSE; import static com.android.documentsui.BaseActivity.State.ACTION_BROWSE_ALL; import static com.android.documentsui.BaseActivity.State.ACTION_CREATE; import static com.android.documentsui.BaseActivity.State.ACTION_MANAGE; import static com.android.documentsui.BaseActivity.State.MODE_GRID; import static com.android.documentsui.BaseActivity.State.MODE_LIST; import static com.android.documentsui.BaseActivity.State.MODE_UNKNOWN; import static com.android.documentsui.BaseActivity.State.SORT_ORDER_UNKNOWN; import static com.android.documentsui.DocumentsActivity.TAG; import static com.android.documentsui.model.DocumentInfo.getCursorInt; import static com.android.documentsui.model.DocumentInfo.getCursorLong; import static com.android.documentsui.model.DocumentInfo.getCursorString; import android.app.Activity; import android.app.ActivityManager; import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentTransaction; import android.app.LoaderManager.LoaderCallbacks; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.Loader; import android.content.res.Resources; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Point; import android.graphics.drawable.Drawable; import android.graphics.drawable.InsetDrawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Handler; import android.os.Looper; import android.os.OperationCanceledException; import android.os.Parcelable; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.text.TextUtils; import android.text.format.DateUtils; import android.text.format.Formatter; import android.text.format.Time; import android.util.Log; import android.util.SparseArray; import android.util.SparseBooleanArray; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.AbsListView.MultiChoiceModeListener; import android.widget.AbsListView.RecyclerListener; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.BaseAdapter; import android.widget.GridView; import android.widget.ImageView; import android.widget.ListView; import android.widget.TextView; import android.widget.Toast; import com.android.documentsui.BaseActivity.State; import com.android.documentsui.ProviderExecutor.Preemptable; import com.android.documentsui.RecentsProvider.StateColumns; import com.android.documentsui.model.DocumentInfo; import com.android.documentsui.model.DocumentStack; import com.android.documentsui.model.RootInfo; import com.google.android.collect.Lists; import java.util.ArrayList; import java.util.List; /** * Display the documents inside a single directory. */ public class DirectoryFragment extends Fragment { private View mEmptyView; private ListView mListView; private GridView mGridView; private AbsListView mCurrentView; public static final int TYPE_NORMAL = 1; public static final int TYPE_SEARCH = 2; public static final int TYPE_RECENT_OPEN = 3; public static final int ANIM_NONE = 1; public static final int ANIM_SIDE = 2; public static final int ANIM_DOWN = 3; public static final int ANIM_UP = 4; public static final int REQUEST_COPY_DESTINATION = 1; private int mType = TYPE_NORMAL; private String mStateKey; private int mLastMode = MODE_UNKNOWN; private int mLastSortOrder = SORT_ORDER_UNKNOWN; private boolean mLastShowSize = false; private boolean mHideGridTitles = false; private boolean mSvelteRecents; private Point mThumbSize; private DocumentsAdapter mAdapter; private LoaderCallbacks mCallbacks; private static final String EXTRA_TYPE = "type"; private static final String EXTRA_ROOT = "root"; private static final String EXTRA_DOC = "doc"; private static final String EXTRA_QUERY = "query"; private static final String EXTRA_IGNORE_STATE = "ignoreState"; private final int mLoaderId = 42; private final Handler mHandler = new Handler(Looper.getMainLooper()); public static void showNormal(FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) { show(fm, TYPE_NORMAL, root, doc, null, anim); } public static void showSearch(FragmentManager fm, RootInfo root, String query, int anim) { show(fm, TYPE_SEARCH, root, null, query, anim); } public static void showRecentsOpen(FragmentManager fm, int anim) { show(fm, TYPE_RECENT_OPEN, null, null, null, anim); } private static void show(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, String query, int anim) { final Bundle args = new Bundle(); args.putInt(EXTRA_TYPE, type); args.putParcelable(EXTRA_ROOT, root); args.putParcelable(EXTRA_DOC, doc); args.putString(EXTRA_QUERY, query); final FragmentTransaction ft = fm.beginTransaction(); switch (anim) { case ANIM_SIDE: args.putBoolean(EXTRA_IGNORE_STATE, true); break; case ANIM_DOWN: args.putBoolean(EXTRA_IGNORE_STATE, true); ft.setCustomAnimations(R.animator.dir_down, R.animator.dir_frozen); break; case ANIM_UP: ft.setCustomAnimations(R.animator.dir_frozen, R.animator.dir_up); break; } final DirectoryFragment fragment = new DirectoryFragment(); fragment.setArguments(args); ft.replace(R.id.container_directory, fragment); ft.commitAllowingStateLoss(); } private static String buildStateKey(RootInfo root, DocumentInfo doc) { final StringBuilder builder = new StringBuilder(); builder.append(root != null ? root.authority : "null").append(';'); builder.append(root != null ? root.rootId : "null").append(';'); builder.append(doc != null ? doc.documentId : "null"); return builder.toString(); } public static DirectoryFragment get(FragmentManager fm) { // TODO: deal with multiple directories shown at once return (DirectoryFragment) fm.findFragmentById(R.id.container_directory); } @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final Context context = inflater.getContext(); final Resources res = context.getResources(); final View view = inflater.inflate(R.layout.fragment_directory, container, false); mEmptyView = view.findViewById(android.R.id.empty); mListView = (ListView) view.findViewById(R.id.list); mListView.setOnItemClickListener(mItemListener); mListView.setMultiChoiceModeListener(mMultiListener); mListView.setRecyclerListener(mRecycleListener); // Indent our list divider to align with text final Drawable divider = mListView.getDivider(); final boolean insetLeft = res.getBoolean(R.bool.list_divider_inset_left); final int insetSize = res.getDimensionPixelSize(R.dimen.list_divider_inset); if (insetLeft) { mListView.setDivider(new InsetDrawable(divider, insetSize, 0, 0, 0)); } else { mListView.setDivider(new InsetDrawable(divider, 0, 0, insetSize, 0)); } mGridView = (GridView) view.findViewById(R.id.grid); mGridView.setOnItemClickListener(mItemListener); mGridView.setMultiChoiceModeListener(mMultiListener); mGridView.setRecyclerListener(mRecycleListener); return view; } @Override public void onDestroyView() { super.onDestroyView(); // Cancel any outstanding thumbnail requests final ViewGroup target = (mListView.getAdapter() != null) ? mListView : mGridView; final int count = target.getChildCount(); for (int i = 0; i < count; i++) { final View view = target.getChildAt(i); mRecycleListener.onMovedToScrapHeap(view); } // Tear down any selection in progress mListView.setChoiceMode(AbsListView.CHOICE_MODE_NONE); mGridView.setChoiceMode(AbsListView.CHOICE_MODE_NONE); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); final Context context = getActivity(); final State state = getDisplayState(DirectoryFragment.this); final RootInfo root = getArguments().getParcelable(EXTRA_ROOT); final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC); mAdapter = new DocumentsAdapter(); mType = getArguments().getInt(EXTRA_TYPE); mStateKey = buildStateKey(root, doc); if (mType == TYPE_RECENT_OPEN) { // Hide titles when showing recents for picking images/videos mHideGridTitles = MimePredicate.mimeMatches( MimePredicate.VISUAL_MIMES, state.acceptMimes); } else { mHideGridTitles = (doc != null) && doc.isGridTitlesHidden(); } final ActivityManager am = (ActivityManager) context.getSystemService( Context.ACTIVITY_SERVICE); mSvelteRecents = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN); mCallbacks = new LoaderCallbacks() { @Override public Loader onCreateLoader(int id, Bundle args) { final String query = getArguments().getString(EXTRA_QUERY); Uri contentsUri; switch (mType) { case TYPE_NORMAL: contentsUri = DocumentsContract.buildChildDocumentsUri( doc.authority, doc.documentId); if (state.action == ACTION_MANAGE) { contentsUri = DocumentsContract.setManageMode(contentsUri); } return new DirectoryLoader( context, mType, root, doc, contentsUri, state.userSortOrder); case TYPE_SEARCH: contentsUri = DocumentsContract.buildSearchDocumentsUri( root.authority, root.rootId, query); if (state.action == ACTION_MANAGE) { contentsUri = DocumentsContract.setManageMode(contentsUri); } return new DirectoryLoader( context, mType, root, doc, contentsUri, state.userSortOrder); case TYPE_RECENT_OPEN: final RootsCache roots = DocumentsApplication.getRootsCache(context); return new RecentLoader(context, roots, state); default: throw new IllegalStateException("Unknown type " + mType); } } @Override public void onLoadFinished(Loader loader, DirectoryResult result) { if (result == null || result.exception != null) { // onBackPressed does a fragment transaction, which can't be done inside // onLoadFinished mHandler.post(new Runnable() { @Override public void run() { final Activity activity = getActivity(); if (activity != null) { activity.onBackPressed(); } } }); return; } if (!isAdded()) return; mAdapter.swapResult(result); // Push latest state up to UI // TODO: if mode change was racing with us, don't overwrite it if (result.mode != MODE_UNKNOWN) { state.derivedMode = result.mode; } state.derivedSortOrder = result.sortOrder; ((BaseActivity) context).onStateChanged(); updateDisplayState(); // When launched into empty recents, show drawer if (mType == TYPE_RECENT_OPEN && mAdapter.isEmpty() && !state.stackTouched && context instanceof DocumentsActivity) { ((DocumentsActivity) context).setRootsDrawerOpen(true); } // Restore any previous instance state final SparseArray container = state.dirState.remove(mStateKey); if (container != null && !getArguments().getBoolean(EXTRA_IGNORE_STATE, false)) { getView().restoreHierarchyState(container); } else if (mLastSortOrder != state.derivedSortOrder) { mListView.smoothScrollToPosition(0); mGridView.smoothScrollToPosition(0); } mLastSortOrder = state.derivedSortOrder; } @Override public void onLoaderReset(Loader loader) { mAdapter.swapResult(null); } }; // Kick off loader at least once getLoaderManager().restartLoader(mLoaderId, null, mCallbacks); updateDisplayState(); } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { // There's only one request code right now. Replace this with a switch statement or // something more scalable when more codes are added. if (requestCode != REQUEST_COPY_DESTINATION) { return; } if (resultCode == Activity.RESULT_CANCELED || data == null) { // User pressed the back button or otherwise cancelled the destination pick. Don't // proceed with the copy. return; } CopyService.start(getActivity(), getDisplayState(this).selectedDocumentsForCopy, (DocumentStack) data.getParcelableExtra(CopyService.EXTRA_STACK)); } @Override public void onStop() { super.onStop(); // Remember last scroll location final SparseArray container = new SparseArray(); getView().saveHierarchyState(container); final State state = getDisplayState(this); state.dirState.put(mStateKey, container); } @Override public void onResume() { super.onResume(); updateDisplayState(); } public void onDisplayStateChanged() { updateDisplayState(); } public void onUserSortOrderChanged() { // Sort order change always triggers reload; we'll trigger state change // on the flip side. getLoaderManager().restartLoader(mLoaderId, null, mCallbacks); } public void onUserModeChanged() { final ContentResolver resolver = getActivity().getContentResolver(); final State state = getDisplayState(this); final RootInfo root = getArguments().getParcelable(EXTRA_ROOT); final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC); if (root != null && doc != null) { final Uri stateUri = RecentsProvider.buildState( root.authority, root.rootId, doc.documentId); final ContentValues values = new ContentValues(); values.put(StateColumns.MODE, state.userMode); new AsyncTask() { @Override protected Void doInBackground(Void... params) { resolver.insert(stateUri, values); return null; } }.execute(); } // Mode change is just visual change; no need to kick loader, and // deliver change event immediately. state.derivedMode = state.userMode; ((BaseActivity) getActivity()).onStateChanged(); updateDisplayState(); } private void updateDisplayState() { final State state = getDisplayState(this); if (mLastMode == state.derivedMode && mLastShowSize == state.showSize) return; mLastMode = state.derivedMode; mLastShowSize = state.showSize; mListView.setVisibility(state.derivedMode == MODE_LIST ? View.VISIBLE : View.GONE); mGridView.setVisibility(state.derivedMode == MODE_GRID ? View.VISIBLE : View.GONE); final int choiceMode; if (state.allowMultiple) { choiceMode = ListView.CHOICE_MODE_MULTIPLE_MODAL; } else { choiceMode = ListView.CHOICE_MODE_NONE; } final int thumbSize; if (state.derivedMode == MODE_GRID) { thumbSize = getResources().getDimensionPixelSize(R.dimen.grid_width); mListView.setAdapter(null); mListView.setChoiceMode(ListView.CHOICE_MODE_NONE); mGridView.setAdapter(mAdapter); mGridView.setColumnWidth(getResources().getDimensionPixelSize(R.dimen.grid_width)); mGridView.setNumColumns(GridView.AUTO_FIT); mGridView.setChoiceMode(choiceMode); mCurrentView = mGridView; } else if (state.derivedMode == MODE_LIST) { thumbSize = getResources().getDimensionPixelSize(R.dimen.icon_size); mGridView.setAdapter(null); mGridView.setChoiceMode(ListView.CHOICE_MODE_NONE); mListView.setAdapter(mAdapter); mListView.setChoiceMode(choiceMode); mCurrentView = mListView; } else { throw new IllegalStateException("Unknown state " + state.derivedMode); } mThumbSize = new Point(thumbSize, thumbSize); } private OnItemClickListener mItemListener = new OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { final Cursor cursor = mAdapter.getItem(position); if (cursor != null) { final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); if (isDocumentEnabled(docMimeType, docFlags)) { final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor); ((BaseActivity) getActivity()).onDocumentPicked(doc); } } } }; private MultiChoiceModeListener mMultiListener = new MultiChoiceModeListener() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { mode.getMenuInflater().inflate(R.menu.mode_directory, menu); mode.setTitle(TextUtils.formatSelectedCount(mCurrentView.getCheckedItemCount())); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { final State state = getDisplayState(DirectoryFragment.this); final MenuItem open = menu.findItem(R.id.menu_open); final MenuItem share = menu.findItem(R.id.menu_share); final MenuItem delete = menu.findItem(R.id.menu_delete); final MenuItem copy = menu.findItem(R.id.menu_copy); final boolean manageOrBrowse = (state.action == ACTION_MANAGE || state.action == ACTION_BROWSE || state.action == ACTION_BROWSE_ALL); open.setVisible(!manageOrBrowse); share.setVisible(manageOrBrowse); delete.setVisible(manageOrBrowse); // Disable copying from the Recents view. copy.setVisible(manageOrBrowse && mType != TYPE_RECENT_OPEN); return true; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { final SparseBooleanArray checked = mCurrentView.getCheckedItemPositions(); final ArrayList docs = Lists.newArrayList(); final int size = checked.size(); for (int i = 0; i < size; i++) { if (checked.valueAt(i)) { final Cursor cursor = mAdapter.getItem(checked.keyAt(i)); final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor); docs.add(doc); } } final int id = item.getItemId(); if (id == R.id.menu_open) { BaseActivity.get(DirectoryFragment.this).onDocumentsPicked(docs); mode.finish(); return true; } else if (id == R.id.menu_share) { onShareDocuments(docs); mode.finish(); return true; } else if (id == R.id.menu_delete) { onDeleteDocuments(docs); mode.finish(); return true; } else if (id == R.id.menu_copy) { onCopyDocuments(docs); mode.finish(); return true; } else if (id == R.id.menu_select_all) { int count = mCurrentView.getCount(); for (int i = 0; i < count; i++) { mCurrentView.setItemChecked(i, true); } updateDisplayState(); return true; } else { return false; } } @Override public void onDestroyActionMode(ActionMode mode) { // ignored } @Override public void onItemCheckedStateChanged( ActionMode mode, int position, long id, boolean checked) { if (checked) { // Directories and footer items cannot be checked boolean valid = false; final Cursor cursor = mAdapter.getItem(position); if (cursor != null) { final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); valid = isDocumentEnabled(docMimeType, docFlags); } if (!valid) { mCurrentView.setItemChecked(position, false); } } mode.setTitle(TextUtils.formatSelectedCount(mCurrentView.getCheckedItemCount())); } }; private RecyclerListener mRecycleListener = new RecyclerListener() { @Override public void onMovedToScrapHeap(View view) { final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb); if (iconThumb != null) { final ThumbnailAsyncTask oldTask = (ThumbnailAsyncTask) iconThumb.getTag(); if (oldTask != null) { oldTask.preempt(); iconThumb.setTag(null); } } } }; private void onShareDocuments(List docs) { Intent intent; // Filter out directories - those can't be shared. List docsForSend = Lists.newArrayList(); for (DocumentInfo doc: docs) { if (!Document.MIME_TYPE_DIR.equals(doc.mimeType)) { docsForSend.add(doc); } } if (docsForSend.size() == 1) { final DocumentInfo doc = docsForSend.get(0); intent = new Intent(Intent.ACTION_SEND); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addCategory(Intent.CATEGORY_DEFAULT); intent.setType(doc.mimeType); intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri); } else if (docsForSend.size() > 1) { intent = new Intent(Intent.ACTION_SEND_MULTIPLE); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addCategory(Intent.CATEGORY_DEFAULT); final ArrayList mimeTypes = Lists.newArrayList(); final ArrayList uris = Lists.newArrayList(); for (DocumentInfo doc : docsForSend) { mimeTypes.add(doc.mimeType); uris.add(doc.derivedUri); } intent.setType(findCommonMimeType(mimeTypes)); intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); } else { return; } intent = Intent.createChooser(intent, getActivity().getText(R.string.share_via)); startActivity(intent); } private void onDeleteDocuments(List docs) { final Context context = getActivity(); final ContentResolver resolver = context.getContentResolver(); boolean hadTrouble = false; for (DocumentInfo doc : docs) { if (!doc.isDeleteSupported()) { Log.w(TAG, "Skipping " + doc); hadTrouble = true; continue; } ContentProviderClient client = null; try { client = DocumentsApplication.acquireUnstableProviderOrThrow( resolver, doc.derivedUri.getAuthority()); DocumentsContract.deleteDocument(client, doc.derivedUri); } catch (Exception e) { Log.w(TAG, "Failed to delete " + doc); hadTrouble = true; } finally { ContentProviderClient.releaseQuietly(client); } } if (hadTrouble) { Toast.makeText(context, R.string.toast_failed_delete, Toast.LENGTH_SHORT).show(); } } private void onCopyDocuments(List docs) { getDisplayState(this).selectedDocumentsForCopy = docs; // Pop up a dialog to pick a destination. This is inadequate but works for now. // TODO: Implement a picker that is to spec. final Intent intent = new Intent( BaseActivity.DocumentsIntent.ACTION_OPEN_COPY_DESTINATION, Uri.EMPTY, getActivity(), DocumentsActivity.class); boolean directoryCopy = false; for (DocumentInfo info : docs) { if (Document.MIME_TYPE_DIR.equals(info.mimeType)) { directoryCopy = true; break; } } intent.putExtra(BaseActivity.DocumentsIntent.EXTRA_DIRECTORY_COPY, directoryCopy); startActivityForResult(intent, REQUEST_COPY_DESTINATION); } private static State getDisplayState(Fragment fragment) { return ((BaseActivity) fragment.getActivity()).getDisplayState(); } private static abstract class Footer { private final int mItemViewType; public Footer(int itemViewType) { mItemViewType = itemViewType; } public abstract View getView(View convertView, ViewGroup parent); public int getItemViewType() { return mItemViewType; } } private class LoadingFooter extends Footer { public LoadingFooter() { super(1); } @Override public View getView(View convertView, ViewGroup parent) { final Context context = parent.getContext(); final State state = getDisplayState(DirectoryFragment.this); if (convertView == null) { final LayoutInflater inflater = LayoutInflater.from(context); if (state.derivedMode == MODE_LIST) { convertView = inflater.inflate(R.layout.item_loading_list, parent, false); } else if (state.derivedMode == MODE_GRID) { convertView = inflater.inflate(R.layout.item_loading_grid, parent, false); } else { throw new IllegalStateException(); } } return convertView; } } private class MessageFooter extends Footer { private final int mIcon; private final String mMessage; public MessageFooter(int itemViewType, int icon, String message) { super(itemViewType); mIcon = icon; mMessage = message; } @Override public View getView(View convertView, ViewGroup parent) { final Context context = parent.getContext(); final State state = getDisplayState(DirectoryFragment.this); if (convertView == null) { final LayoutInflater inflater = LayoutInflater.from(context); if (state.derivedMode == MODE_LIST) { convertView = inflater.inflate(R.layout.item_message_list, parent, false); } else if (state.derivedMode == MODE_GRID) { convertView = inflater.inflate(R.layout.item_message_grid, parent, false); } else { throw new IllegalStateException(); } } final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon); final TextView title = (TextView) convertView.findViewById(android.R.id.title); icon.setImageResource(mIcon); title.setText(mMessage); return convertView; } } private class DocumentsAdapter extends BaseAdapter { private Cursor mCursor; private int mCursorCount; private List