OSDN Git Service

Merge "Automatically open roots pane if default dir is empty." into nyc-dev
[android-x86/frameworks-base.git] / packages / DocumentsUI / src / com / android / documentsui / dirlist / DirectoryFragment.java
1 /*
2  * Copyright (C) 2013 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package com.android.documentsui.dirlist;
18
19 import static com.android.documentsui.Shared.DEBUG;
20 import static com.android.documentsui.State.ACTION_MANAGE;
21 import static com.android.documentsui.State.MODE_GRID;
22 import static com.android.documentsui.State.MODE_LIST;
23 import static com.android.documentsui.State.SORT_ORDER_UNKNOWN;
24 import static com.android.documentsui.model.DocumentInfo.getCursorInt;
25 import static com.android.documentsui.model.DocumentInfo.getCursorString;
26 import static com.android.internal.util.Preconditions.checkNotNull;
27 import static com.android.internal.util.Preconditions.checkState;
28 import static com.google.common.base.Preconditions.checkArgument;
29
30 import android.annotation.IntDef;
31 import android.annotation.StringRes;
32 import android.app.Activity;
33 import android.app.ActivityManager;
34 import android.app.Fragment;
35 import android.app.FragmentManager;
36 import android.app.FragmentTransaction;
37 import android.app.LoaderManager.LoaderCallbacks;
38 import android.content.ClipData;
39 import android.content.ContentResolver;
40 import android.content.Context;
41 import android.content.Intent;
42 import android.content.Loader;
43 import android.database.Cursor;
44 import android.graphics.Canvas;
45 import android.graphics.Point;
46 import android.graphics.drawable.Drawable;
47 import android.net.Uri;
48 import android.os.AsyncTask;
49 import android.os.Bundle;
50 import android.os.Parcelable;
51 import android.provider.DocumentsContract;
52 import android.provider.DocumentsContract.Document;
53 import android.support.annotation.Nullable;
54 import android.support.design.widget.Snackbar;
55 import android.support.v7.widget.GridLayoutManager;
56 import android.support.v7.widget.GridLayoutManager.SpanSizeLookup;
57 import android.support.v7.widget.RecyclerView;
58 import android.support.v7.widget.RecyclerView.OnItemTouchListener;
59 import android.support.v7.widget.RecyclerView.RecyclerListener;
60 import android.support.v7.widget.RecyclerView.ViewHolder;
61 import android.text.TextUtils;
62 import android.util.Log;
63 import android.util.SparseArray;
64 import android.util.TypedValue;
65 import android.view.ActionMode;
66 import android.view.DragEvent;
67 import android.view.GestureDetector;
68 import android.view.KeyEvent;
69 import android.view.LayoutInflater;
70 import android.view.Menu;
71 import android.view.MenuItem;
72 import android.view.MotionEvent;
73 import android.view.View;
74 import android.view.ViewGroup;
75 import android.view.ViewParent;
76 import android.widget.ImageView;
77 import android.widget.TextView;
78
79 import com.android.documentsui.BaseActivity;
80 import com.android.documentsui.DirectoryLoader;
81 import com.android.documentsui.DirectoryResult;
82 import com.android.documentsui.DocumentClipper;
83 import com.android.documentsui.DocumentsActivity;
84 import com.android.documentsui.DocumentsApplication;
85 import com.android.documentsui.Events;
86 import com.android.documentsui.Events.MotionInputEvent;
87 import com.android.documentsui.Menus;
88 import com.android.documentsui.MessageBar;
89 import com.android.documentsui.MimePredicate;
90 import com.android.documentsui.R;
91 import com.android.documentsui.RecentLoader;
92 import com.android.documentsui.RootsCache;
93 import com.android.documentsui.Shared;
94 import com.android.documentsui.Snackbars;
95 import com.android.documentsui.State;
96 import com.android.documentsui.State.ViewMode;
97 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
98 import com.android.documentsui.model.DocumentInfo;
99 import com.android.documentsui.model.DocumentStack;
100 import com.android.documentsui.model.RootInfo;
101 import com.android.documentsui.services.FileOperationService;
102 import com.android.documentsui.services.FileOperationService.OpType;
103 import com.android.documentsui.services.FileOperations;
104
105 import com.google.common.collect.Lists;
106
107 import java.lang.annotation.Retention;
108 import java.lang.annotation.RetentionPolicy;
109 import java.util.ArrayList;
110 import java.util.Collections;
111 import java.util.List;
112
113 /**
114  * Display the documents inside a single directory.
115  */
116 public class DirectoryFragment extends Fragment implements DocumentsAdapter.Environment {
117
118     @IntDef(flag = true, value = {
119             TYPE_NORMAL,
120             TYPE_SEARCH,
121             TYPE_RECENT_OPEN
122     })
123     @Retention(RetentionPolicy.SOURCE)
124     public @interface ResultType {}
125     public static final int TYPE_NORMAL = 1;
126     public static final int TYPE_SEARCH = 2;
127     public static final int TYPE_RECENT_OPEN = 3;
128
129     public static final int ANIM_NONE = 1;
130     public static final int ANIM_SIDE = 2;
131     public static final int ANIM_LEAVE = 3;
132     public static final int ANIM_ENTER = 4;
133
134     public static final int REQUEST_COPY_DESTINATION = 1;
135
136     static final boolean DEBUG_ENABLE_DND = true;
137
138     private static final String TAG = "DirectoryFragment";
139     private static final int LOADER_ID = 42;
140     private static final int DELETE_UNDO_TIMEOUT = 5000;
141     private static final int DELETE_JOB_DELAY = 5500;
142     private static final int EMPTY_REVEAL_DURATION = 250;
143
144     private static final String EXTRA_TYPE = "type";
145     private static final String EXTRA_ROOT = "root";
146     private static final String EXTRA_DOC = "doc";
147     private static final String EXTRA_QUERY = "query";
148     private static final String EXTRA_IGNORE_STATE = "ignoreState";
149
150     private Model mModel;
151     private MultiSelectManager mSelectionManager;
152     private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener();
153     private ItemEventListener mItemEventListener = new ItemEventListener();
154
155     private IconHelper mIconHelper;
156
157     private View mEmptyView;
158     private RecyclerView mRecView;
159     private ListeningGestureDetector mGestureDetector;
160
161     private @ResultType int mType = TYPE_NORMAL;
162     private String mStateKey;
163
164     private int mLastSortOrder = SORT_ORDER_UNKNOWN;
165     private DocumentsAdapter mAdapter;
166     private LoaderCallbacks<DirectoryResult> mCallbacks;
167     private FragmentTuner mTuner;
168     private DocumentClipper mClipper;
169     private GridLayoutManager mLayout;
170     private int mColumnCount = 1;  // This will get updated when layout changes.
171
172     private MessageBar mMessageBar;
173     private View mProgressBar;
174
175     @Override
176     public View onCreateView(
177             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
178         final View view = inflater.inflate(R.layout.fragment_directory, container, false);
179
180         mMessageBar = MessageBar.create(getChildFragmentManager());
181         mProgressBar = view.findViewById(R.id.progressbar);
182
183         mEmptyView = view.findViewById(android.R.id.empty);
184
185         mRecView = (RecyclerView) view.findViewById(R.id.list);
186         mRecView.setRecyclerListener(
187                 new RecyclerListener() {
188                     @Override
189                     public void onViewRecycled(ViewHolder holder) {
190                         cancelThumbnailTask(holder.itemView);
191                     }
192                 });
193
194         mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity()));
195
196         // TODO: Add a divider between views (which might use RecyclerView.ItemDecoration).
197         if (DEBUG_ENABLE_DND) {
198             setupDragAndDropOnDirectoryView(mRecView);
199         }
200
201         return view;
202     }
203
204     @Override
205     public void onDestroyView() {
206         super.onDestroyView();
207
208         // Cancel any outstanding thumbnail requests
209         final int count = mRecView.getChildCount();
210         for (int i = 0; i < count; i++) {
211             final View view = mRecView.getChildAt(i);
212             cancelThumbnailTask(view);
213         }
214
215         // Clear any outstanding selection
216         mSelectionManager.clearSelection();
217     }
218
219     @Override
220     public void onActivityCreated(Bundle savedInstanceState) {
221         super.onActivityCreated(savedInstanceState);
222
223         final Context context = getActivity();
224         final State state = getDisplayState();
225
226         final RootInfo root = getArguments().getParcelable(EXTRA_ROOT);
227         final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC);
228
229         mIconHelper = new IconHelper(context, MODE_GRID);
230
231         mAdapter = new SectionBreakDocumentsAdapterWrapper(
232                 this, new ModelBackedDocumentsAdapter(this, mIconHelper));
233
234         mRecView.setAdapter(mAdapter);
235
236         mLayout = new GridLayoutManager(getContext(), mColumnCount);
237         SpanSizeLookup lookup = mAdapter.createSpanSizeLookup();
238         if (lookup != null) {
239             mLayout.setSpanSizeLookup(lookup);
240         }
241         mRecView.setLayoutManager(mLayout);
242
243         mGestureDetector = new ListeningGestureDetector(this.getContext(), new GestureListener());
244
245         mRecView.addOnItemTouchListener(mGestureDetector);
246
247         // TODO: instead of inserting the view into the constructor, extract listener-creation code
248         // and set the listener on the view after the fact.  Then the view doesn't need to be passed
249         // into the selection manager.
250         mSelectionManager = new MultiSelectManager(
251                 mRecView,
252                 mAdapter,
253                 state.allowMultiple
254                     ? MultiSelectManager.MODE_MULTIPLE
255                     : MultiSelectManager.MODE_SINGLE);
256         mSelectionManager.addCallback(new SelectionModeListener());
257
258         mModel = new Model();
259         mModel.addUpdateListener(mAdapter);
260         mModel.addUpdateListener(mModelUpdateListener);
261
262         mType = getArguments().getInt(EXTRA_TYPE);
263         mStateKey = buildStateKey(root, doc);
264
265         mTuner = FragmentTuner.pick(getContext(), state);
266         mClipper = new DocumentClipper(context);
267
268         boolean hideGridTitles;
269         if (mType == TYPE_RECENT_OPEN) {
270             // Hide titles when showing recents for picking images/videos
271             hideGridTitles = MimePredicate.mimeMatches(
272                     MimePredicate.VISUAL_MIMES, state.acceptMimes);
273         } else {
274             hideGridTitles = (doc != null) && doc.isGridTitlesHidden();
275         }
276         GridDocumentHolder.setHideTitles(hideGridTitles);
277
278         final ActivityManager am = (ActivityManager) context.getSystemService(
279                 Context.ACTIVITY_SERVICE);
280         boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN);
281         mIconHelper.setThumbnailsEnabled(!svelte);
282
283         mCallbacks = new LoaderCallbacks<DirectoryResult>() {
284             @Override
285             public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
286                 final String query = getArguments().getString(EXTRA_QUERY);
287
288                 Uri contentsUri;
289                 switch (mType) {
290                     case TYPE_NORMAL:
291                         contentsUri = DocumentsContract.buildChildDocumentsUri(
292                                 doc.authority, doc.documentId);
293                         if (state.action == ACTION_MANAGE) {
294                             contentsUri = DocumentsContract.setManageMode(contentsUri);
295                         }
296                         return new DirectoryLoader(
297                                 context, mType, root, doc, contentsUri, state.userSortOrder);
298                     case TYPE_SEARCH:
299                         contentsUri = DocumentsContract.buildSearchDocumentsUri(
300                                 root.authority, root.rootId, query);
301                         if (state.action == ACTION_MANAGE) {
302                             contentsUri = DocumentsContract.setManageMode(contentsUri);
303                         }
304                         return new DirectoryLoader(
305                                 context, mType, root, doc, contentsUri, state.userSortOrder);
306                     case TYPE_RECENT_OPEN:
307                         final RootsCache roots = DocumentsApplication.getRootsCache(context);
308                         return new RecentLoader(context, roots, state);
309                     default:
310                         throw new IllegalStateException("Unknown type " + mType);
311                 }
312             }
313
314             @Override
315             public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
316                 if (!isAdded()) return;
317
318                 mModel.update(result);
319                 state.derivedSortOrder = result.sortOrder;
320
321                 updateDisplayState();
322
323                 // Restore any previous instance state
324                 final SparseArray<Parcelable> container = state.dirState.remove(mStateKey);
325                 if (container != null && !getArguments().getBoolean(EXTRA_IGNORE_STATE, false)) {
326                     getView().restoreHierarchyState(container);
327                 } else if (mLastSortOrder != state.derivedSortOrder) {
328                     // The derived sort order takes the user sort order into account, but applies
329                     // directory-specific defaults when the user doesn't explicitly set the sort
330                     // order. Scroll to the top if the sort order actually changed.
331                     mRecView.smoothScrollToPosition(0);
332                 }
333
334                 mLastSortOrder = state.derivedSortOrder;
335
336                 mTuner.onModelLoaded(mModel, mType);
337             }
338
339             @Override
340             public void onLoaderReset(Loader<DirectoryResult> loader) {
341                 mModel.update(null);
342             }
343         };
344
345         // Kick off loader at least once
346         getLoaderManager().restartLoader(LOADER_ID, null, mCallbacks);
347     }
348
349     @Override
350     public void onActivityResult(int requestCode, int resultCode, Intent data) {
351         // There's only one request code right now. Replace this with a switch statement or
352         // something more scalable when more codes are added.
353         if (requestCode != REQUEST_COPY_DESTINATION) {
354             return;
355         }
356         if (resultCode == Activity.RESULT_CANCELED || data == null) {
357             // User pressed the back button or otherwise cancelled the destination pick. Don't
358             // proceed with the copy.
359             return;
360         }
361
362         int operationType = data.getIntExtra(
363                 FileOperationService.EXTRA_OPERATION,
364                 FileOperationService.OPERATION_COPY);
365
366         FileOperations.start(
367                 getActivity(),
368                 getDisplayState().selectedDocumentsForCopy,
369                 getDisplayState().stack.peek(),
370                 (DocumentStack) data.getParcelableExtra(Shared.EXTRA_STACK),
371                 operationType);
372     }
373
374     protected boolean onDoubleTap(MotionEvent e) {
375         if (Events.isMouseEvent(e)) {
376             String id = getModelId(e);
377             if (id != null) {
378                 return handleViewItem(id);
379             }
380         }
381         return false;
382     }
383
384     private boolean handleViewItem(String id) {
385         final Cursor cursor = mModel.getItem(id);
386         checkNotNull(cursor, "Cursor cannot be null.");
387         final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
388         final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
389         if (mTuner.isDocumentEnabled(docMimeType, docFlags)) {
390             final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
391             ((BaseActivity) getActivity()).onDocumentPicked(doc, mModel);
392             mSelectionManager.clearSelection();
393             return true;
394         }
395         return false;
396     }
397
398     @Override
399     public void onStop() {
400         super.onStop();
401
402         // Remember last scroll location
403         final SparseArray<Parcelable> container = new SparseArray<Parcelable>();
404         getView().saveHierarchyState(container);
405         final State state = getDisplayState();
406         state.dirState.put(mStateKey, container);
407     }
408
409     public void onDisplayStateChanged() {
410         updateDisplayState();
411     }
412
413     public void onSortOrderChanged() {
414         // Sort order is implemented as a sorting wrapper around directory
415         // results. So when sort order changes, we force a reload of the directory.
416         getLoaderManager().restartLoader(LOADER_ID, null, mCallbacks);
417     }
418
419     public void onViewModeChanged() {
420         // Mode change is just visual change; no need to kick loader.
421         updateDisplayState();
422     }
423
424     private void updateDisplayState() {
425         State state = getDisplayState();
426         updateLayout(state.derivedMode);
427         mRecView.setAdapter(mAdapter);
428     }
429
430     /**
431      * Updates the layout after the view mode switches.
432      * @param mode The new view mode.
433      */
434     private void updateLayout(@ViewMode int mode) {
435         mColumnCount = calculateColumnCount(mode);
436         if (mLayout != null) {
437             mLayout.setSpanCount(mColumnCount);
438         }
439
440         int pad = getDirectoryPadding(mode);
441         mRecView.setPadding(pad, pad, pad, pad);
442         mRecView.requestLayout();
443         mSelectionManager.handleLayoutChanged();  // RecyclerView doesn't do this for us
444         mIconHelper.setViewMode(mode);
445     }
446
447     private int calculateColumnCount(@ViewMode int mode) {
448         if (mode == MODE_LIST) {
449             // List mode is a "grid" with 1 column.
450             return 1;
451         }
452
453         int cellWidth = getResources().getDimensionPixelSize(R.dimen.grid_width);
454         int cellMargin = 2 * getResources().getDimensionPixelSize(R.dimen.grid_item_margin);
455         int viewPadding = mRecView.getPaddingLeft() + mRecView.getPaddingRight();
456
457         checkState(mRecView.getWidth() > 0);
458         int columnCount = Math.max(1,
459                 (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin));
460
461         return columnCount;
462     }
463
464     private int getDirectoryPadding(@ViewMode int mode) {
465         switch (mode) {
466             case MODE_GRID:
467                 return getResources().getDimensionPixelSize(R.dimen.grid_container_padding);
468             case MODE_LIST:
469                 return getResources().getDimensionPixelSize(R.dimen.list_container_padding);
470             default:
471                 throw new IllegalArgumentException("Unsupported layout mode: " + mode);
472         }
473     }
474
475     @Override
476     public int getColumnCount() {
477         return mColumnCount;
478     }
479
480     /**
481      * Manages the integration between our ActionMode and MultiSelectManager, initiating
482      * ActionMode when there is a selection, canceling it when there is no selection,
483      * and clearing selection when action mode is explicitly exited by the user.
484      */
485     private final class SelectionModeListener
486             implements MultiSelectManager.Callback, ActionMode.Callback {
487
488         private Selection mSelected = new Selection();
489         private ActionMode mActionMode;
490         private int mNoDeleteCount = 0;
491         private int mNoRenameCount = -1;
492         private Menu mMenu;
493
494         @Override
495         public boolean onBeforeItemStateChange(String modelId, boolean selected) {
496             if (selected) {
497                 final Cursor cursor = mModel.getItem(modelId);
498                 checkNotNull(cursor, "Cursor cannot be null.");
499                 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
500                 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
501                 return mTuner.canSelectType(docMimeType, docFlags);
502             }
503             return true;
504         }
505
506         @Override
507         public void onItemStateChanged(String modelId, boolean selected) {
508             final Cursor cursor = mModel.getItem(modelId);
509             checkNotNull(cursor, "Cursor cannot be null.");
510
511             // TODO: Should this be happening in onSelectionChanged? Technically this callback is
512             // triggered on "silent" selection updates (i.e. we might be reacting to unfinalized
513             // selection changes here)
514             final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
515             if ((docFlags & Document.FLAG_SUPPORTS_DELETE) == 0) {
516                 mNoDeleteCount += selected ? 1 : -1;
517             }
518             if ((docFlags & Document.FLAG_SUPPORTS_RENAME) != 0) {
519                 mNoRenameCount += selected ? 1 : -1;
520             }
521         }
522
523         @Override
524         public void onSelectionChanged() {
525             mSelectionManager.getSelection(mSelected);
526             TypedValue color = new TypedValue();
527             if (mSelected.size() > 0) {
528                 if (DEBUG) Log.d(TAG, "Maybe starting action mode.");
529                 if (mActionMode == null) {
530                     if (DEBUG) Log.d(TAG, "Yeah. Starting action mode.");
531                     mActionMode = getActivity().startActionMode(this);
532                 }
533                 getActivity().getTheme().resolveAttribute(R.attr.colorActionMode, color, true);
534                 updateActionMenu();
535             } else {
536                 if (DEBUG) Log.d(TAG, "Finishing action mode.");
537                 if (mActionMode != null) {
538                     mActionMode.finish();
539                 }
540                 getActivity().getTheme().resolveAttribute(
541                     android.R.attr.colorPrimaryDark, color, true);
542             }
543             getActivity().getWindow().setStatusBarColor(color.data);
544
545             if (mActionMode != null) {
546                 mActionMode.setTitle(String.valueOf(mSelected.size()));
547             }
548         }
549
550         // Called when the user exits the action mode
551         @Override
552         public void onDestroyActionMode(ActionMode mode) {
553             if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
554             mActionMode = null;
555             // clear selection
556             mSelectionManager.clearSelection();
557             mSelected.clear();
558             mNoDeleteCount = 0;
559             mNoRenameCount = -1;
560         }
561
562         @Override
563         public boolean onCreateActionMode(ActionMode mode, Menu menu) {
564             int size = mSelectionManager.getSelection().size();
565             mode.getMenuInflater().inflate(R.menu.mode_directory, menu);
566             mode.setTitle(TextUtils.formatSelectedCount(size));
567             return (size > 0);
568         }
569
570         @Override
571         public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
572             mMenu = menu;
573             updateActionMenu();
574             return true;
575         }
576
577         boolean canRenameSelection() {
578             return mNoRenameCount == 0 && mSelectionManager.getSelection().size() == 1;
579         }
580
581         boolean canDeleteSelection() {
582             return mNoDeleteCount == 0;
583         }
584
585         private void updateActionMenu() {
586             checkNotNull(mMenu);
587
588             // Delegate update logic to our owning action, since specialized logic is desired.
589             mTuner.updateActionMenu(mMenu, mType, canDeleteSelection(), canRenameSelection());
590             Menus.disableHiddenItems(mMenu);
591         }
592
593         @Override
594         public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
595
596             Selection selection = mSelectionManager.getSelection(new Selection());
597
598             switch (item.getItemId()) {
599                 case R.id.menu_open:
600                     openDocuments(selection);
601                     mode.finish();
602                     return true;
603
604                 case R.id.menu_share:
605                     shareDocuments(selection);
606                     mode.finish();
607                     return true;
608
609                 case R.id.menu_delete:
610                     // Exit selection mode first, so we avoid deselecting deleted documents.
611                     mode.finish();
612                     deleteDocuments(selection);
613                     return true;
614
615                 case R.id.menu_copy_to:
616                     transferDocuments(selection, FileOperationService.OPERATION_COPY);
617                     mode.finish();
618                     return true;
619
620                 case R.id.menu_move_to:
621                     // Exit selection mode first, so we avoid deselecting deleted documents.
622                     mode.finish();
623                     transferDocuments(selection, FileOperationService.OPERATION_MOVE);
624                     return true;
625
626                 case R.id.menu_copy_to_clipboard:
627                     copySelectedToClipboard();
628                     return true;
629
630                 case R.id.menu_select_all:
631                     selectAllFiles();
632                     return true;
633
634                 case R.id.menu_rename:
635                     renameDocuments(selection);
636                     mode.finish();
637                     return true;
638
639                 default:
640                     if (DEBUG) Log.d(TAG, "Unhandled menu item selected: " + item);
641                     return false;
642             }
643         }
644     }
645
646     public final boolean onBackPressed() {
647         if (mSelectionManager.hasSelection()) {
648             if (DEBUG) Log.d(TAG, "Clearing selection on back pressed.");
649             mSelectionManager.clearSelection();
650             return true;
651         }
652         return false;
653     }
654
655     private void cancelThumbnailTask(View view) {
656         final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb);
657         if (iconThumb != null) {
658             mIconHelper.stopLoading(iconThumb);
659         }
660     }
661
662     private void openDocuments(final Selection selected) {
663         new GetDocumentsTask() {
664             @Override
665             void onDocumentsReady(List<DocumentInfo> docs) {
666                 // TODO: Implement support in Files activity for opening multiple docs.
667                 BaseActivity.get(DirectoryFragment.this).onDocumentsPicked(docs);
668             }
669         }.execute(selected);
670     }
671
672     private void shareDocuments(final Selection selected) {
673         new GetDocumentsTask() {
674             @Override
675             void onDocumentsReady(List<DocumentInfo> docs) {
676                 Intent intent;
677
678                 // Filter out directories - those can't be shared.
679                 List<DocumentInfo> docsForSend = new ArrayList<>();
680                 for (DocumentInfo doc: docs) {
681                     if (!Document.MIME_TYPE_DIR.equals(doc.mimeType)) {
682                         docsForSend.add(doc);
683                     }
684                 }
685
686                 if (docsForSend.size() == 1) {
687                     final DocumentInfo doc = docsForSend.get(0);
688
689                     intent = new Intent(Intent.ACTION_SEND);
690                     intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
691                     intent.addCategory(Intent.CATEGORY_DEFAULT);
692                     intent.setType(doc.mimeType);
693                     intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri);
694
695                 } else if (docsForSend.size() > 1) {
696                     intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
697                     intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
698                     intent.addCategory(Intent.CATEGORY_DEFAULT);
699
700                     final ArrayList<String> mimeTypes = new ArrayList<>();
701                     final ArrayList<Uri> uris = new ArrayList<>();
702                     for (DocumentInfo doc : docsForSend) {
703                         mimeTypes.add(doc.mimeType);
704                         uris.add(doc.derivedUri);
705                     }
706
707                     intent.setType(findCommonMimeType(mimeTypes));
708                     intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
709
710                 } else {
711                     return;
712                 }
713
714                 intent = Intent.createChooser(intent, getActivity().getText(R.string.share_via));
715                 startActivity(intent);
716             }
717         }.execute(selected);
718     }
719
720     private void deleteDocuments(final Selection selected) {
721
722         checkArgument(!selected.isEmpty());
723         final DocumentInfo srcParent = getDisplayState().stack.peek();
724         new GetDocumentsTask() {
725             @Override
726             void onDocumentsReady(List<DocumentInfo> docs) {
727                 // Hide the files in the UI.
728                 final SparseArray<String> hidden = mAdapter.hide(selected.getAll());
729
730                 checkState(DELETE_JOB_DELAY > DELETE_UNDO_TIMEOUT);
731                 String operationId = FileOperations.delete(
732                         getActivity(), docs, srcParent, getDisplayState().stack,
733                         DELETE_JOB_DELAY);
734                 showDeleteSnackbar(hidden, operationId);
735             }
736         }.execute(selected);
737     }
738
739     private void showDeleteSnackbar(final SparseArray<String> hidden, final String jobId) {
740
741         Context context = getActivity();
742         String message = Shared.getQuantityString(context, R.plurals.deleting, hidden.size());
743
744         // Show a snackbar informing the user that files will be deleted, and give them an option to
745         // cancel.
746         final Activity activity = getActivity();
747         Snackbars.makeSnackbar(activity, message, DELETE_UNDO_TIMEOUT)
748                 .setAction(
749                         R.string.undo,
750                         new View.OnClickListener() {
751                             @Override
752                             public void onClick(View view) {}
753                         })
754                 .setCallback(
755                         new Snackbar.Callback() {
756                             @Override
757                             public void onDismissed(Snackbar snackbar, int event) {
758                                 if (event == Snackbar.Callback.DISMISS_EVENT_ACTION) {
759                                     // If the delete was cancelled, just unhide the files.
760                                     FileOperations.cancel(activity, jobId);
761                                     mAdapter.unhide(hidden);
762                                 }
763                             }
764                         })
765                 .show();
766     }
767
768     private void transferDocuments(final Selection selected, final @OpType int mode) {
769         // Pop up a dialog to pick a destination.  This is inadequate but works for now.
770         // TODO: Implement a picker that is to spec.
771         final Intent intent = new Intent(
772                 Shared.ACTION_PICK_COPY_DESTINATION,
773                 Uri.EMPTY,
774                 getActivity(),
775                 DocumentsActivity.class);
776
777         new GetDocumentsTask() {
778             @Override
779             void onDocumentsReady(List<DocumentInfo> docs) {
780                 getDisplayState().selectedDocumentsForCopy = docs;
781
782                 boolean directoryCopy = false;
783                 for (DocumentInfo info : docs) {
784                     if (Document.MIME_TYPE_DIR.equals(info.mimeType)) {
785                         directoryCopy = true;
786                         break;
787                     }
788                 }
789                 intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, directoryCopy);
790                 intent.putExtra(FileOperationService.EXTRA_OPERATION, mode);
791                 startActivityForResult(intent, REQUEST_COPY_DESTINATION);
792             }
793         }.execute(selected);
794     }
795
796     private void renameDocuments(Selection selected) {
797         // Batch renaming not supported
798         // Rename option is only available in menu when 1 document selected
799         checkArgument(selected.size() == 1);
800
801         new GetDocumentsTask() {
802             @Override
803             void onDocumentsReady(List<DocumentInfo> docs) {
804                 RenameDocumentFragment.show(getFragmentManager(), docs.get(0));
805             }
806         }.execute(selected);
807     }
808
809     @Override
810     public void initDocumentHolder(DocumentHolder holder) {
811         holder.addEventListener(mItemEventListener);
812     }
813
814     @Override
815     public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) {
816         if (DEBUG_ENABLE_DND) {
817             setupDragAndDropOnDocumentView(holder.itemView, cursor);
818         }
819     }
820
821     @Override
822     public State getDisplayState() {
823         return ((BaseActivity) getActivity()).getDisplayState();
824     }
825
826     @Override
827     public Model getModel() {
828         return mModel;
829     }
830
831     @Override
832     public boolean isDocumentEnabled(String docMimeType, int docFlags) {
833         return mTuner.isDocumentEnabled(docMimeType, docFlags);
834     }
835
836     private void showEmptyDirectory() {
837         showEmptyView(R.string.empty, R.drawable.cabinet);
838     }
839
840     private void showNoResults(RootInfo root) {
841         CharSequence msg = getContext().getResources().getText(R.string.no_results);
842         showEmptyView(String.format(String.valueOf(msg), root.title), R.drawable.cabinet);
843     }
844
845     private void showQueryError() {
846         showEmptyView(R.string.query_error, R.drawable.hourglass);
847     }
848
849     private void showEmptyView(@StringRes int id, int drawable) {
850         showEmptyView(getContext().getResources().getText(id), drawable);
851     }
852
853     private void showEmptyView(CharSequence msg, int drawable) {
854         View content = mEmptyView.findViewById(R.id.content);
855         TextView msgView = (TextView) mEmptyView.findViewById(R.id.message);
856         ImageView imageView = (ImageView) mEmptyView.findViewById(R.id.artwork);
857         msgView.setText(msg);
858         imageView.setImageResource(drawable);
859
860         mEmptyView.setVisibility(View.VISIBLE);
861         mRecView.setVisibility(View.GONE);
862     }
863
864     private void showDirectory() {
865         mEmptyView.setVisibility(View.GONE);
866         mRecView.setVisibility(View.VISIBLE);
867     }
868
869     private String findCommonMimeType(List<String> mimeTypes) {
870         String[] commonType = mimeTypes.get(0).split("/");
871         if (commonType.length != 2) {
872             return "*/*";
873         }
874
875         for (int i = 1; i < mimeTypes.size(); i++) {
876             String[] type = mimeTypes.get(i).split("/");
877             if (type.length != 2) continue;
878
879             if (!commonType[1].equals(type[1])) {
880                 commonType[1] = "*";
881             }
882
883             if (!commonType[0].equals(type[0])) {
884                 commonType[0] = "*";
885                 commonType[1] = "*";
886                 break;
887             }
888         }
889
890         return commonType[0] + "/" + commonType[1];
891     }
892
893     private void copyFromClipboard() {
894         new AsyncTask<Void, Void, List<DocumentInfo>>() {
895
896             @Override
897             protected List<DocumentInfo> doInBackground(Void... params) {
898                 return mClipper.getClippedDocuments();
899             }
900
901             @Override
902             protected void onPostExecute(List<DocumentInfo> docs) {
903                 DocumentInfo destination =
904                         ((BaseActivity) getActivity()).getCurrentDirectory();
905                 copyDocuments(docs, destination);
906             }
907         }.execute();
908     }
909
910     private void copyFromClipData(final ClipData clipData, final DocumentInfo destination) {
911         checkNotNull(clipData);
912         new AsyncTask<Void, Void, List<DocumentInfo>>() {
913
914             @Override
915             protected List<DocumentInfo> doInBackground(Void... params) {
916                 return mClipper.getDocumentsFromClipData(clipData);
917             }
918
919             @Override
920             protected void onPostExecute(List<DocumentInfo> docs) {
921                 copyDocuments(docs, destination);
922             }
923         }.execute();
924     }
925
926     private void copyDocuments(final List<DocumentInfo> docs, final DocumentInfo destination) {
927         if (!canCopy(docs, destination)) {
928             Snackbars.makeSnackbar(
929                     getActivity(),
930                     R.string.clipboard_files_cannot_paste,
931                     Snackbar.LENGTH_SHORT)
932                     .show();
933             return;
934         }
935
936         if (docs.isEmpty()) {
937             return;
938         }
939
940         final DocumentStack curStack = getDisplayState().stack;
941         DocumentStack tmpStack = new DocumentStack();
942         if (destination != null) {
943             tmpStack.push(destination);
944             tmpStack.addAll(curStack);
945         } else {
946             tmpStack = curStack;
947         }
948
949         FileOperations.copy(getActivity(), docs, tmpStack);
950     }
951
952     private ClipData getClipDataFromDocuments(List<DocumentInfo> docs) {
953         Context context = getActivity();
954         final ContentResolver resolver = context.getContentResolver();
955         ClipData clipData = null;
956         for (DocumentInfo doc : docs) {
957             final Uri uri = DocumentsContract.buildDocumentUri(doc.authority, doc.documentId);
958             if (clipData == null) {
959                 // TODO: figure out what this string should be.
960                 // Currently it is not displayed anywhere in the UI, but this might change.
961                 final String label = "";
962                 clipData = ClipData.newUri(resolver, label, uri);
963             } else {
964                 // TODO: update list of mime types in ClipData.
965                 clipData.addItem(new ClipData.Item(uri));
966             }
967         }
968         return clipData;
969     }
970
971     public void copySelectedToClipboard() {
972         Selection selection = mSelectionManager.getSelection(new Selection());
973         if (!selection.isEmpty()) {
974             copySelectionToClipboard(selection);
975             mSelectionManager.clearSelection();
976         }
977     }
978
979     void copySelectionToClipboard(Selection selection) {
980         checkArgument(!selection.isEmpty());
981         new GetDocumentsTask() {
982             @Override
983             void onDocumentsReady(List<DocumentInfo> docs) {
984                 mClipper.clipDocuments(docs);
985                 Activity activity = getActivity();
986                 Snackbars.makeSnackbar(activity,
987                         activity.getResources().getQuantityString(
988                                 R.plurals.clipboard_files_clipped, docs.size(), docs.size()),
989                         Snackbar.LENGTH_SHORT).show();
990             }
991         }.execute(selection);
992     }
993
994     public void pasteFromClipboard() {
995         copyFromClipboard();
996         getActivity().invalidateOptionsMenu();
997     }
998
999     /**
1000      * Returns true if the list of files can be copied to destination. Note that this
1001      * is a policy check only. Currently the method does not attempt to verify
1002      * available space or any other environmental aspects possibly resulting in
1003      * failure to copy.
1004      *
1005      * @return true if the list of files can be copied to destination.
1006      */
1007     boolean canCopy(List<DocumentInfo> files, DocumentInfo dest) {
1008         BaseActivity activity = (BaseActivity) getActivity();
1009
1010         final RootInfo root = activity.getCurrentRoot();
1011
1012         // Can't copy folders to Downloads.
1013         if (root.isDownloads()) {
1014             for (DocumentInfo docs : files) {
1015                 if (docs.isDirectory()) {
1016                     return false;
1017                 }
1018             }
1019         }
1020
1021         return dest != null && dest.isDirectory() && dest.isCreateSupported();
1022     }
1023
1024     public void selectAllFiles() {
1025         // Only select things currently visible in the adapter.
1026         boolean changed = mSelectionManager.setItemsSelected(mAdapter.getModelIds(), true);
1027         if (changed) {
1028             updateDisplayState();
1029         }
1030     }
1031
1032     private void setupDragAndDropOnDirectoryView(View view) {
1033         // Listen for drops on non-directory items and empty space.
1034         view.setOnDragListener(mOnDragListener);
1035     }
1036
1037     private void setupDragAndDropOnDocumentView(View view, Cursor cursor) {
1038         final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
1039         if (Document.MIME_TYPE_DIR.equals(docMimeType)) {
1040             // Make a directory item a drop target. Drop on non-directories and empty space
1041             // is handled at the list/grid view level.
1042             view.setOnDragListener(mOnDragListener);
1043         }
1044
1045         view.setOnLongClickListener(mLongClickListener);
1046     }
1047
1048     private View.OnDragListener mOnDragListener = new View.OnDragListener() {
1049         @Override
1050         public boolean onDrag(View v, DragEvent event) {
1051             switch (event.getAction()) {
1052                 case DragEvent.ACTION_DRAG_STARTED:
1053                     // TODO: Check if the event contains droppable data.
1054                     return true;
1055
1056                 // TODO: Highlight potential drop target directory?
1057                 // TODO: Expand drop target directory on hover?
1058                 case DragEvent.ACTION_DRAG_ENTERED:
1059                 case DragEvent.ACTION_DRAG_LOCATION:
1060                 case DragEvent.ACTION_DRAG_EXITED:
1061                 case DragEvent.ACTION_DRAG_ENDED:
1062                     return true;
1063
1064                 case DragEvent.ACTION_DROP:
1065                     String dstId = getModelId(v);
1066                     DocumentInfo dstDir = null;
1067                     if (dstId != null) {
1068                         Cursor dstCursor = mModel.getItem(dstId);
1069                         checkNotNull(dstCursor, "Cursor cannot be null.");
1070                         dstDir = DocumentInfo.fromDirectoryCursor(dstCursor);
1071                         // TODO: Do not drop into the directory where the documents came from.
1072                     }
1073                     copyFromClipData(event.getClipData(), dstDir);
1074                     return true;
1075             }
1076             return false;
1077         }
1078     };
1079
1080     /**
1081      * Gets the model ID for a given motion event (using the event position)
1082      */
1083     private String getModelId(MotionEvent e) {
1084         View view = mRecView.findChildViewUnder(e.getX(), e.getY());
1085         if (view == null) {
1086             return null;
1087         }
1088         RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(view);
1089         if (vh instanceof DocumentHolder) {
1090             return ((DocumentHolder) vh).modelId;
1091         } else {
1092             return null;
1093         }
1094     }
1095
1096     /**
1097      * Gets the model ID for a given RecyclerView item.
1098      * @param view A View that is a document item view, or a child of a document item view.
1099      * @return The Model ID for the given document, or null if the given view is not associated with
1100      *     a document item view.
1101      */
1102     private String getModelId(View view) {
1103         while (true) {
1104             if (view.getLayoutParams() instanceof RecyclerView.LayoutParams) {
1105                 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(view);
1106                 if (vh instanceof DocumentHolder) {
1107                     return ((DocumentHolder) vh).modelId;
1108                 } else {
1109                     return null;
1110                 }
1111             }
1112             ViewParent parent = view.getParent();
1113             if (parent == null || !(parent instanceof View)) {
1114                 return null;
1115             }
1116             view = (View) parent;
1117         }
1118     }
1119
1120     private List<DocumentInfo> getDraggableDocuments(View currentItemView) {
1121         String modelId = getModelId(currentItemView);
1122         if (modelId == null) {
1123             return Collections.EMPTY_LIST;
1124         }
1125
1126         final List<DocumentInfo> selectedDocs =
1127                 mModel.getDocuments(mSelectionManager.getSelection());
1128         if (!selectedDocs.isEmpty()) {
1129             if (!isSelected(modelId)) {
1130                 // There is a selection that does not include the current item, drag nothing.
1131                 return Collections.EMPTY_LIST;
1132             }
1133             return selectedDocs;
1134         }
1135
1136         final Cursor cursor = mModel.getItem(modelId);
1137         checkNotNull(cursor, "Cursor cannot be null.");
1138         final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
1139
1140         return Lists.newArrayList(doc);
1141     }
1142
1143     private Drawable getDragShadowIcon(List<DocumentInfo> docs) {
1144         if (docs.size() == 1) {
1145             final DocumentInfo doc = docs.get(0);
1146             return mIconHelper.getDocumentIcon(getActivity(), doc.authority, doc.documentId,
1147                     doc.mimeType, doc.icon);
1148         }
1149         return getActivity().getDrawable(R.drawable.ic_doc_generic);
1150     }
1151
1152     private class DrawableShadowBuilder extends View.DragShadowBuilder {
1153
1154         private final Drawable mShadow;
1155
1156         private final int mShadowDimension;
1157
1158         public DrawableShadowBuilder(Drawable shadow) {
1159             mShadow = shadow;
1160             mShadowDimension = getResources().getDimensionPixelSize(
1161                     R.dimen.drag_shadow_size);
1162             mShadow.setBounds(0, 0, mShadowDimension, mShadowDimension);
1163         }
1164
1165         @Override
1166         public void onProvideShadowMetrics(
1167                 Point shadowSize, Point shadowTouchPoint) {
1168             shadowSize.set(mShadowDimension, mShadowDimension);
1169             shadowTouchPoint.set(mShadowDimension / 2, mShadowDimension / 2);
1170         }
1171
1172         @Override
1173         public void onDrawShadow(Canvas canvas) {
1174             mShadow.draw(canvas);
1175         }
1176     }
1177
1178     /**
1179      * Abstract task providing support for loading documents *off*
1180      * the main thread. And if it isn't obvious, creating a list
1181      * of documents (especially large lists) can be pretty expensive.
1182      */
1183     private abstract class GetDocumentsTask
1184             extends AsyncTask<Selection, Void, List<DocumentInfo>> {
1185         @Override
1186         protected final List<DocumentInfo> doInBackground(Selection... selected) {
1187             return mModel.getDocuments(selected[0]);
1188         }
1189
1190         @Override
1191         protected final void onPostExecute(List<DocumentInfo> docs) {
1192             onDocumentsReady(docs);
1193         }
1194
1195         abstract void onDocumentsReady(List<DocumentInfo> docs);
1196     }
1197
1198     @Override
1199     public boolean isSelected(String modelId) {
1200         return mSelectionManager.getSelection().contains(modelId);
1201     }
1202
1203     private class ItemEventListener implements DocumentHolder.EventListener {
1204         @Override
1205         public boolean onActivate(DocumentHolder doc) {
1206             // Toggle selection if we're in selection mode, othewise, view item.
1207             if (mSelectionManager.hasSelection()) {
1208                 mSelectionManager.toggleSelection(doc.modelId);
1209             } else {
1210                 handleViewItem(doc.modelId);
1211             }
1212             return true;
1213         }
1214
1215         @Override
1216         public boolean onSelect(DocumentHolder doc) {
1217             mSelectionManager.toggleSelection(doc.modelId);
1218             mSelectionManager.setSelectionRangeBegin(doc.getAdapterPosition());
1219             return true;
1220         }
1221
1222         @Override
1223         public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
1224             // Only handle key-down events. This is simpler, consistent with most other UIs, and
1225             // enables the handling of repeated key events from holding down a key.
1226             if (event.getAction() != KeyEvent.ACTION_DOWN) {
1227                 return false;
1228             }
1229
1230             boolean handled = false;
1231             if (Events.isNavigationKeyCode(keyCode)) {
1232                 // Find the target item and focus it.
1233                 int endPos = findTargetPosition(doc.itemView, keyCode);
1234
1235                 if (endPos != RecyclerView.NO_POSITION) {
1236                     focusItem(endPos);
1237
1238                     // Handle any necessary adjustments to selection.
1239                     boolean extendSelection = event.isShiftPressed();
1240                     if (extendSelection) {
1241                         int startPos = doc.getAdapterPosition();
1242                         mSelectionManager.selectRange(startPos, endPos);
1243                     }
1244                     handled = true;
1245                 }
1246             } else {
1247                 // Handle enter key events
1248                 if (keyCode == KeyEvent.KEYCODE_ENTER) {
1249                     handled = onActivate(doc);
1250                 }
1251             }
1252
1253             return handled;
1254         }
1255
1256         /**
1257          * Finds the destination position where the focus should land for a given navigation event.
1258          *
1259          * @param view The view that received the event.
1260          * @param keyCode The key code for the event.
1261          * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
1262          */
1263         private int findTargetPosition(View view, int keyCode) {
1264             switch (keyCode) {
1265                 case KeyEvent.KEYCODE_MOVE_HOME:
1266                     return 0;
1267                 case KeyEvent.KEYCODE_MOVE_END:
1268                     return mAdapter.getItemCount() - 1;
1269                 case KeyEvent.KEYCODE_PAGE_UP:
1270                 case KeyEvent.KEYCODE_PAGE_DOWN:
1271                     return findTargetPositionByPage(view, keyCode);
1272             }
1273
1274             // Find a navigation target based on the arrow key that the user pressed.
1275             int searchDir = -1;
1276             switch (keyCode) {
1277                 case KeyEvent.KEYCODE_DPAD_UP:
1278                     searchDir = View.FOCUS_UP;
1279                     break;
1280                 case KeyEvent.KEYCODE_DPAD_DOWN:
1281                     searchDir = View.FOCUS_DOWN;
1282                     break;
1283                 case KeyEvent.KEYCODE_DPAD_LEFT:
1284                     searchDir = View.FOCUS_LEFT;
1285                     break;
1286                 case KeyEvent.KEYCODE_DPAD_RIGHT:
1287                     searchDir = View.FOCUS_RIGHT;
1288                     break;
1289             }
1290
1291             if (searchDir != -1) {
1292                 View targetView = view.focusSearch(searchDir);
1293                 // TargetView can be null, for example, if the user pressed <down> at the bottom
1294                 // of the list.
1295                 if (targetView != null) {
1296                     // Ignore navigation targets that aren't items in the RecyclerView.
1297                     if (targetView.getParent() == mRecView) {
1298                         return mRecView.getChildAdapterPosition(targetView);
1299                     }
1300                 }
1301             }
1302
1303             return RecyclerView.NO_POSITION;
1304         }
1305
1306         /**
1307          * Given a PgUp/PgDn event and the current view, find the position of the target view.
1308          * This returns:
1309          * <li>The position of the topmost (or bottom-most) visible item, if the current item is not
1310          *     the top- or bottom-most visible item.
1311          * <li>The position of an item that is one page's worth of items up (or down) if the current
1312          *      item is the top- or bottom-most visible item.
1313          * <li>The first (or last) item, if paging up (or down) would go past those limits.
1314          * @param view The view that received the key event.
1315          * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
1316          * @return The adapter position of the target item.
1317          */
1318         private int findTargetPositionByPage(View view, int keyCode) {
1319             int first = mLayout.findFirstVisibleItemPosition();
1320             int last = mLayout.findLastVisibleItemPosition();
1321             int current = mRecView.getChildAdapterPosition(view);
1322             int pageSize = last - first + 1;
1323
1324             if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
1325                 if (current > first) {
1326                     // If the current item isn't the first item, target the first item.
1327                     return first;
1328                 } else {
1329                     // If the current item is the first item, target the item one page up.
1330                     int target = current - pageSize;
1331                     return target < 0 ? 0 : target;
1332                 }
1333             }
1334
1335             if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
1336                 if (current < last) {
1337                     // If the current item isn't the last item, target the last item.
1338                     return last;
1339                 } else {
1340                     // If the current item is the last item, target the item one page down.
1341                     int target = current + pageSize;
1342                     int max = mAdapter.getItemCount() - 1;
1343                     return target < max ? target : max;
1344                 }
1345             }
1346
1347             throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
1348         }
1349
1350         /**
1351          * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
1352          * necessary.
1353          *
1354          * @param pos
1355          */
1356         public void focusItem(final int pos) {
1357             // If the item is already in view, focus it; otherwise, scroll to it and focus it.
1358             RecyclerView.ViewHolder vh = mRecView.findViewHolderForAdapterPosition(pos);
1359             if (vh != null) {
1360                 vh.itemView.requestFocus();
1361             } else {
1362                 mRecView.smoothScrollToPosition(pos);
1363                 // Set a one-time listener to request focus when the scroll has completed.
1364                 mRecView.addOnScrollListener(
1365                     new RecyclerView.OnScrollListener() {
1366                         @Override
1367                         public void onScrollStateChanged (RecyclerView view, int newState) {
1368                             if (newState == RecyclerView.SCROLL_STATE_IDLE) {
1369                                 // When scrolling stops, find the item and focus it.
1370                                 RecyclerView.ViewHolder vh =
1371                                         view.findViewHolderForAdapterPosition(pos);
1372                                 if (vh != null) {
1373                                     vh.itemView.requestFocus();
1374                                 } else {
1375                                     // This might happen in weird corner cases, e.g. if the user is
1376                                     // scrolling while a delete operation is in progress.  In that
1377                                     // case, just don't attempt to focus the missing item.
1378                                     Log.w(
1379                                         TAG, "Unable to focus position " + pos + " after a scroll");
1380                                 }
1381                                 view.removeOnScrollListener(this);
1382                             }
1383                         }
1384                     });
1385             }
1386         }
1387
1388
1389     }
1390
1391     private final class ModelUpdateListener implements Model.UpdateListener {
1392         @Override
1393         public void onModelUpdate(Model model) {
1394             if (model.info != null || model.error != null) {
1395                 mMessageBar.setInfo(model.info);
1396                 mMessageBar.setError(model.error);
1397                 mMessageBar.show();
1398             }
1399
1400             mProgressBar.setVisibility(model.isLoading() ? View.VISIBLE : View.GONE);
1401
1402             if (model.isEmpty()) {
1403                 if (getDisplayState().currentSearch != null) {
1404                     showNoResults(getDisplayState().stack.root);
1405                 } else {
1406                     showEmptyDirectory();
1407                 }
1408             } else {
1409                 showDirectory();
1410                 mAdapter.notifyDataSetChanged();
1411             }
1412         }
1413
1414         @Override
1415         public void onModelUpdateFailed(Exception e) {
1416             showQueryError();
1417         }
1418     }
1419
1420     private View.OnLongClickListener mLongClickListener = new View.OnLongClickListener() {
1421         @Override
1422         public boolean onLongClick(View v) {
1423             if (mGestureDetector.mouseSpawnedLastEvent()) {
1424                 List<DocumentInfo> docs = getDraggableDocuments(v);
1425                 if (docs.isEmpty()) {
1426                     return false;
1427                 }
1428                 v.startDrag(
1429                         getClipDataFromDocuments(docs),
1430                         new DrawableShadowBuilder(getDragShadowIcon(docs)),
1431                         null,
1432                         View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ |
1433                                 View.DRAG_FLAG_GLOBAL_URI_WRITE
1434                 );
1435                 return true;
1436             }
1437
1438             return false;
1439         }
1440     };
1441
1442     // Previously we listened to events with one class, only to bounce them forward
1443     // to GestureDetector. We're still doing that here, but with a single class
1444     // that reduces overall complexity in our glue code.
1445     private static final class ListeningGestureDetector extends GestureDetector
1446             implements OnItemTouchListener {
1447
1448         private int mLastTool = -1;
1449
1450         public ListeningGestureDetector(Context context, GestureListener listener) {
1451             super(context, listener);
1452             setOnDoubleTapListener(listener);
1453         }
1454
1455         boolean mouseSpawnedLastEvent() {
1456             return Events.isMouseType(mLastTool);
1457         }
1458
1459         boolean touchSpawnedLastEvent() {
1460             return Events.isTouchType(mLastTool);
1461         }
1462
1463         @Override
1464         public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
1465             mLastTool = e.getToolType(0);
1466             onTouchEvent(e);  // bounce this forward to our detecty heart
1467             return false;
1468         }
1469
1470         @Override
1471         public void onTouchEvent(RecyclerView rv, MotionEvent e) {}
1472
1473         @Override
1474         public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
1475     }
1476
1477     /**
1478      * The gesture listener for items in the list/grid view. Interprets gestures and sends the
1479      * events to the target DocumentHolder, whence they are routed to the appropriate listener.
1480      */
1481     private class GestureListener extends GestureDetector.SimpleOnGestureListener {
1482         @Override
1483         public boolean onSingleTapUp(MotionEvent e) {
1484             // Single tap logic:
1485             // If the selection manager is active, it gets first whack at handling tap
1486             // events. Otherwise, tap events are routed to the target DocumentHolder.
1487             boolean handled = mSelectionManager.onSingleTapUp(
1488                         new MotionInputEvent(e, mRecView));
1489
1490             if (handled) {
1491                 return handled;
1492             }
1493
1494             // Give the DocumentHolder a crack at the event.
1495             DocumentHolder holder = getTarget(e);
1496             if (holder != null) {
1497                 handled = holder.onSingleTapUp(e);
1498             }
1499
1500             return handled;
1501         }
1502
1503         @Override
1504         public void onLongPress(MotionEvent e) {
1505             // Long-press events get routed directly to the selection manager. They can be
1506             // changed to route through the DocumentHolder if necessary.
1507             mSelectionManager.onLongPress(new MotionInputEvent(e, mRecView));
1508         }
1509
1510         @Override
1511         public boolean onDoubleTap(MotionEvent e) {
1512             // Double-tap events are handled directly by the DirectoryFragment. They can be changed
1513             // to route through the DocumentHolder if necessary.
1514             return DirectoryFragment.this.onDoubleTap(e);
1515         }
1516
1517         private @Nullable DocumentHolder getTarget(MotionEvent e) {
1518             View childView = mRecView.findChildViewUnder(e.getX(), e.getY());
1519             if (childView != null) {
1520                 return (DocumentHolder) mRecView.getChildViewHolder(childView);
1521             } else {
1522                 return null;
1523             }
1524         }
1525     }
1526
1527     public static void showDirectory(
1528             FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) {
1529         show(fm, TYPE_NORMAL, root, doc, null, anim);
1530     }
1531
1532     public static void showSearch(FragmentManager fm, RootInfo root, String query, int anim) {
1533         show(fm, TYPE_SEARCH, root, null, query, anim);
1534     }
1535
1536     public static void showRecentsOpen(FragmentManager fm, int anim) {
1537         show(fm, TYPE_RECENT_OPEN, null, null, null, anim);
1538     }
1539
1540     private static void show(FragmentManager fm, int type, RootInfo root, DocumentInfo doc,
1541             String query, int anim) {
1542         final Bundle args = new Bundle();
1543         args.putInt(EXTRA_TYPE, type);
1544         args.putParcelable(EXTRA_ROOT, root);
1545         args.putParcelable(EXTRA_DOC, doc);
1546         args.putString(EXTRA_QUERY, query);
1547
1548         final FragmentTransaction ft = fm.beginTransaction();
1549         switch (anim) {
1550             case ANIM_SIDE:
1551                 args.putBoolean(EXTRA_IGNORE_STATE, true);
1552                 break;
1553             case ANIM_ENTER:
1554                 args.putBoolean(EXTRA_IGNORE_STATE, true);
1555                 ft.setCustomAnimations(R.animator.dir_enter, R.animator.dir_frozen);
1556                 break;
1557             case ANIM_LEAVE:
1558                 ft.setCustomAnimations(R.animator.dir_frozen, R.animator.dir_leave);
1559                 break;
1560         }
1561
1562         final DirectoryFragment fragment = new DirectoryFragment();
1563         fragment.setArguments(args);
1564
1565         ft.replace(R.id.container_directory, fragment);
1566         ft.commitAllowingStateLoss();
1567     }
1568
1569     private static String buildStateKey(RootInfo root, DocumentInfo doc) {
1570         final StringBuilder builder = new StringBuilder();
1571         builder.append(root != null ? root.authority : "null").append(';');
1572         builder.append(root != null ? root.rootId : "null").append(';');
1573         builder.append(doc != null ? doc.documentId : "null");
1574         return builder.toString();
1575     }
1576
1577     public static @Nullable DirectoryFragment get(FragmentManager fm) {
1578         // TODO: deal with multiple directories shown at once
1579         Fragment fragment = fm.findFragmentById(R.id.container_directory);
1580         return fragment instanceof DirectoryFragment
1581                 ? (DirectoryFragment) fragment
1582                 : null;
1583     }
1584 }