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
17package com.android.documentsui.dirlist;
18
19import static com.android.documentsui.base.DocumentInfo.getCursorInt;
20import static com.android.documentsui.base.DocumentInfo.getCursorString;
21import static com.android.documentsui.base.Shared.DEBUG;
22import static com.android.documentsui.base.Shared.VERBOSE;
23import static com.android.documentsui.base.State.MODE_GRID;
24import static com.android.documentsui.base.State.MODE_LIST;
25
26import android.annotation.DimenRes;
27import android.annotation.FractionRes;
28import android.annotation.IntDef;
29import android.app.Activity;
30import android.app.ActivityManager;
31import android.app.Fragment;
32import android.app.FragmentManager;
33import android.app.FragmentTransaction;
34import android.content.ClipData;
35import android.content.Context;
36import android.content.Intent;
37import android.content.res.Resources;
38import android.database.Cursor;
39import android.graphics.drawable.StateListDrawable;
40import android.net.Uri;
41import android.os.Build;
42import android.os.Bundle;
43import android.os.Handler;
44import android.os.Parcelable;
45import android.provider.DocumentsContract;
46import android.provider.DocumentsContract.Document;
47import android.support.v4.widget.SwipeRefreshLayout;
48import android.support.v7.widget.GridLayoutManager;
49import android.support.v7.widget.GridLayoutManager.SpanSizeLookup;
50import android.support.v7.widget.RecyclerView;
51import android.support.v7.widget.RecyclerView.RecyclerListener;
52import android.support.v7.widget.RecyclerView.ViewHolder;
53import android.util.Log;
54import android.util.SparseArray;
55import android.view.ContextMenu;
56import android.view.DragEvent;
57import android.view.HapticFeedbackConstants;
58import android.view.LayoutInflater;
59import android.view.MenuInflater;
60import android.view.MenuItem;
61import android.view.MotionEvent;
62import android.view.View;
63import android.view.ViewGroup;
64import android.widget.ImageView;
65
66import com.android.documentsui.ActionHandler;
67import com.android.documentsui.ActionModeController;
68import com.android.documentsui.BaseActivity;
69import com.android.documentsui.BaseActivity.RetainedState;
70import com.android.documentsui.DirectoryReloadLock;
71import com.android.documentsui.DocumentsApplication;
72import com.android.documentsui.DragAndDropHelper;
73import com.android.documentsui.FocusManager;
74import com.android.documentsui.Injector;
75import com.android.documentsui.Injector.ContentScoped;
76import com.android.documentsui.Injector.Injected;
77import com.android.documentsui.ItemDragListener;
78import com.android.documentsui.Metrics;
79import com.android.documentsui.Model;
80import com.android.documentsui.R;
81import com.android.documentsui.ThumbnailCache;
82import com.android.documentsui.base.DocumentInfo;
83import com.android.documentsui.base.DocumentStack;
84import com.android.documentsui.base.EventHandler;
85import com.android.documentsui.base.EventListener;
86import com.android.documentsui.base.Events.InputEvent;
87import com.android.documentsui.base.Events.MotionInputEvent;
88import com.android.documentsui.base.Features;
89import com.android.documentsui.base.RootInfo;
90import com.android.documentsui.base.Shared;
91import com.android.documentsui.base.State;
92import com.android.documentsui.base.State.ViewMode;
93import com.android.documentsui.clipping.ClipStore;
94import com.android.documentsui.clipping.DocumentClipper;
95import com.android.documentsui.clipping.UrisSupplier;
96import com.android.documentsui.dirlist.AnimationView.AnimationType;
97import com.android.documentsui.picker.PickActivity;
98import com.android.documentsui.selection.BandController;
99import com.android.documentsui.selection.GestureSelector;
100import com.android.documentsui.selection.Selection;
101import com.android.documentsui.selection.SelectionManager;
102import com.android.documentsui.selection.SelectionMetadata;
103import com.android.documentsui.services.FileOperation;
104import com.android.documentsui.services.FileOperationService;
105import com.android.documentsui.services.FileOperationService.OpType;
106import com.android.documentsui.services.FileOperations;
107import com.android.documentsui.sorting.SortDimension;
108import com.android.documentsui.sorting.SortModel;
109
110import java.io.IOException;
111import java.lang.annotation.Retention;
112import java.lang.annotation.RetentionPolicy;
113import java.util.List;
114
115import javax.annotation.Nullable;
116
117/**
118 * Display the documents inside a single directory.
119 */
120public class DirectoryFragment extends Fragment
121        implements ItemDragListener.DragHost, SwipeRefreshLayout.OnRefreshListener {
122
123    static final int TYPE_NORMAL = 1;
124    static final int TYPE_RECENT_OPEN = 2;
125
126    @IntDef(flag = true, value = {
127            REQUEST_COPY_DESTINATION
128    })
129    @Retention(RetentionPolicy.SOURCE)
130    public @interface RequestCode {}
131    public static final int REQUEST_COPY_DESTINATION = 1;
132
133    private static final String TAG = "DirectoryFragment";
134    private static final int LOADER_ID = 42;
135
136    private static final int CACHE_EVICT_LIMIT = 100;
137    private static final int REFRESH_SPINNER_TIMEOUT = 500;
138
139    private BaseActivity mActivity;
140
141    private State mState;
142    private Model mModel;
143    private final EventListener<Model.Update> mModelUpdateListener = new ModelUpdateListener();
144    private final DocumentsAdapter.Environment mAdapterEnv = new AdapterEnvironment();
145
146    @Injected
147    @ContentScoped
148    private Injector<?> mInjector;
149
150    @Injected
151    @ContentScoped
152    private SelectionManager mSelectionMgr;
153
154    @Injected
155    @ContentScoped
156    private FocusManager mFocusManager;
157
158    @Injected
159    @ContentScoped
160    private ActionHandler mActions;
161
162    @Injected
163    @ContentScoped
164    private ActionModeController mActionModeController;
165
166    private SelectionMetadata mSelectionMetadata;
167    private UserInputHandler<InputEvent> mInputHandler;
168    private @Nullable BandController mBandController;
169    private @Nullable DragHoverListener mDragHoverListener;
170    private IconHelper mIconHelper;
171    private SwipeRefreshLayout mRefreshLayout;
172    private RecyclerView mRecView;
173    private View mFileList;
174
175    private DocumentsAdapter mAdapter;
176    private DocumentClipper mClipper;
177    private GridLayoutManager mLayout;
178    private int mColumnCount = 1;  // This will get updated when layout changes.
179
180    private float mLiveScale = 1.0f;
181    private @ViewMode int mMode;
182
183    private View mProgressBar;
184
185    private DirectoryState mLocalState;
186    private DirectoryReloadLock mReloadLock = new DirectoryReloadLock();
187
188    // Note, we use !null to indicate that selection was restored (from rotation).
189    // So don't fiddle with this field unless you've got the bigger picture in mind.
190    private @Nullable Selection mRestoredSelection = null;
191
192    private SortModel.UpdateListener mSortListener = (model, updateType) -> {
193        // Only when sort order has changed do we need to trigger another loading.
194        if ((updateType & SortModel.UPDATE_TYPE_SORTING) != 0) {
195            mActions.loadDocumentsForCurrentStack();
196        }
197    };
198
199    private final Runnable mOnDisplayStateChanged = this::onDisplayStateChanged;
200
201    @Override
202    public View onCreateView(
203            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
204
205        BaseActivity activity = (BaseActivity) getActivity();
206        final View view = inflater.inflate(R.layout.fragment_directory, container, false);
207
208        mProgressBar = view.findViewById(R.id.progressbar);
209        assert(mProgressBar != null);
210
211        mRecView = (RecyclerView) view.findViewById(R.id.dir_list);
212        mRecView.setRecyclerListener(
213                new RecyclerListener() {
214                    @Override
215                    public void onViewRecycled(ViewHolder holder) {
216                        cancelThumbnailTask(holder.itemView);
217                    }
218                });
219
220        mRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refresh_layout);
221        mRefreshLayout.setOnRefreshListener(this);
222
223        Resources resources = getContext().getResources();
224        new FastScroller(mRecView,
225                (StateListDrawable) resources.getDrawable(R.drawable.fast_scroll_thumb_drawable),
226                resources.getDrawable(R.drawable.fast_scroll_track_drawable),
227                (StateListDrawable) resources.getDrawable(R.drawable.fast_scroll_thumb_drawable),
228                resources.getDrawable(R.drawable.fast_scroll_track_drawable),
229                resources.getDimensionPixelSize(R.dimen.fastscroll_default_thickness),
230                resources.getDimensionPixelSize(R.dimen.fastscroll_minimum_range),
231                resources.getDimensionPixelOffset(R.dimen.fastscroll_margin)
232                );
233        mRecView.setItemAnimator(new DirectoryItemAnimator(activity));
234        mFileList = view.findViewById(R.id.file_list);
235
236        mInjector = activity.getInjector();
237        mModel = mInjector.getModel();
238        mModel.reset();
239
240        mInjector.actions.registerDisplayStateChangedListener(mOnDisplayStateChanged);
241
242        mDragHoverListener = mInjector.config.dragAndDropEnabled()
243                ? DragHoverListener.create(new DirectoryDragListener(this), mRecView)
244                : null;
245
246        // Make the recycler and the empty views responsive to drop events when allowed.
247        mRecView.setOnDragListener(mDragHoverListener);
248
249        return view;
250    }
251
252    @Override
253    public void onDestroyView() {
254        mSelectionMgr.clearSelection();
255        mInjector.actions.unregisterDisplayStateChangedListener(mOnDisplayStateChanged);
256
257        // Cancel any outstanding thumbnail requests
258        final int count = mRecView.getChildCount();
259        for (int i = 0; i < count; i++) {
260            final View view = mRecView.getChildAt(i);
261            cancelThumbnailTask(view);
262        }
263
264        mModel.removeUpdateListener(mModelUpdateListener);
265        mModel.removeUpdateListener(mAdapter.getModelUpdateListener());
266
267        super.onDestroyView();
268    }
269
270    @Override
271    public void onActivityCreated(Bundle savedInstanceState) {
272        super.onActivityCreated(savedInstanceState);
273
274        mActivity = (BaseActivity) getActivity();
275        mState = mActivity.getDisplayState();
276
277        // Read arguments when object created for the first time.
278        // Restore state if fragment recreated.
279        Bundle args = savedInstanceState == null ? getArguments() : savedInstanceState;
280
281        mLocalState = new DirectoryState();
282        mLocalState.restore(args);
283
284        // Restore any selection we may have squirreled away in retained state.
285        @Nullable RetainedState retained = mActivity.getRetainedState();
286        if (retained != null && retained.hasSelection()) {
287            // We claim the selection for ourselves and null it out once used
288            // so we don't have a rando selection hanging around in RetainedState.
289            mRestoredSelection = retained.selection;
290            retained.selection = null;
291        }
292
293        mIconHelper = new IconHelper(mActivity, MODE_GRID);
294        mClipper = DocumentsApplication.getDocumentClipper(getContext());
295
296        mAdapter = new DirectoryAddonsAdapter(
297                mAdapterEnv, new ModelBackedDocumentsAdapter(mAdapterEnv, mIconHelper));
298
299        mRecView.setAdapter(mAdapter);
300
301        mLayout = new GridLayoutManager(getContext(), mColumnCount) {
302            @Override
303            public void onLayoutCompleted(RecyclerView.State state) {
304                super.onLayoutCompleted(state);
305                mFocusManager.onLayoutCompleted();
306            }
307        };
308
309        SpanSizeLookup lookup = mAdapter.createSpanSizeLookup();
310        if (lookup != null) {
311            mLayout.setSpanSizeLookup(lookup);
312        }
313        mRecView.setLayoutManager(mLayout);
314
315        mModel.addUpdateListener(mAdapter.getModelUpdateListener());
316        mModel.addUpdateListener(mModelUpdateListener);
317
318        mSelectionMgr = mInjector.getSelectionManager(mAdapter, this::canSetSelectionState);
319        mFocusManager = mInjector.getFocusManager(mRecView, mModel);
320        mActions = mInjector.getActionHandler(mReloadLock);
321
322        mRecView.setAccessibilityDelegateCompat(
323                new AccessibilityEventRouter(mRecView,
324                        (View child) -> onAccessibilityClick(child)));
325        mSelectionMetadata = new SelectionMetadata(mModel::getItem);
326        mSelectionMgr.addItemCallback(mSelectionMetadata);
327
328        GestureSelector gestureSel = GestureSelector.create(mSelectionMgr, mRecView, mReloadLock);
329
330        if (mState.allowMultiple) {
331            mBandController = new BandController(
332                    mRecView,
333                    mAdapter,
334                    mSelectionMgr,
335                    mReloadLock,
336                    (int pos) -> {
337                        // The band selection model only operates on documents and directories.
338                        // Exclude other types of adapter items like whitespace and dividers.
339                        RecyclerView.ViewHolder vh = mRecView.findViewHolderForAdapterPosition(pos);
340                        return ModelBackedDocumentsAdapter.isContentType(vh.getItemViewType());
341                    });
342        }
343
344        DragStartListener mDragStartListener = mInjector.config.dragAndDropEnabled()
345                ? DragStartListener.create(
346                        mIconHelper,
347                        mActivity,
348                        mModel,
349                        mSelectionMgr,
350                        mClipper,
351                        mState,
352                        this::getModelId,
353                        mRecView::findChildViewUnder,
354                        getContext().getDrawable(R.drawable.ic_doc_generic),
355                        mActivity.getShadowBuilder())
356                : DragStartListener.DUMMY;
357
358        EventHandler<InputEvent> gestureHandler = mState.allowMultiple
359                ? gestureSel::start
360                : EventHandler.createStub(false);
361
362        mInputHandler = new UserInputHandler<>(
363                mActions,
364                mFocusManager,
365                mSelectionMgr,
366                (MotionEvent t) -> MotionInputEvent.obtain(t, mRecView),
367                this::canSelect,
368                this::onContextMenuClick,
369                mDragStartListener::onTouchDragEvent,
370                gestureHandler,
371                () -> mRecView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS));
372
373        new ListeningGestureDetector(
374                mInjector.features,
375                this.getContext(),
376                mRecView,
377                mDragStartListener::onMouseDragEvent,
378                mRefreshLayout::setEnabled,
379                gestureSel,
380                mInputHandler,
381                mBandController,
382                this::scaleLayout);
383
384        mActionModeController = mInjector.getActionModeController(
385                mSelectionMetadata,
386                this::handleMenuItemClick);
387
388        mSelectionMgr.addCallback(mActionModeController);
389
390        final ActivityManager am = (ActivityManager) mActivity.getSystemService(
391                Context.ACTIVITY_SERVICE);
392        boolean svelte = am.isLowRamDevice() && (mState.stack.isRecents());
393        mIconHelper.setThumbnailsEnabled(!svelte);
394
395        // If mDocument is null, we sort it by last modified by default because it's in Recents.
396        final boolean prefersLastModified =
397                (mLocalState.mDocument == null)
398                || mLocalState.mDocument.prefersSortByLastModified();
399        // Call this before adding the listener to avoid restarting the loader one more time
400        mState.sortModel.setDefaultDimension(
401                prefersLastModified
402                        ? SortModel.SORT_DIMENSION_ID_DATE
403                        : SortModel.SORT_DIMENSION_ID_TITLE);
404
405        // Kick off loader at least once
406        mActions.loadDocumentsForCurrentStack();
407    }
408
409    @Override
410    public void onStart() {
411        super.onStart();
412
413        // Add listener to update contents on sort model change
414        mState.sortModel.addListener(mSortListener);
415    }
416
417    @Override
418    public void onStop() {
419        super.onStop();
420
421        mState.sortModel.removeListener(mSortListener);
422
423        // Remember last scroll location
424        final SparseArray<Parcelable> container = new SparseArray<>();
425        getView().saveHierarchyState(container);
426        mState.dirConfigs.put(mLocalState.getConfigKey(), container);
427    }
428
429    public void retainState(RetainedState state) {
430        state.selection = mSelectionMgr.getSelection(new Selection());
431    }
432
433    @Override
434    public void onSaveInstanceState(Bundle outState) {
435        super.onSaveInstanceState(outState);
436
437        mLocalState.save(outState);
438    }
439
440    @Override
441    public void onCreateContextMenu(ContextMenu menu,
442            View v,
443            ContextMenu.ContextMenuInfo menuInfo) {
444        super.onCreateContextMenu(menu, v, menuInfo);
445        final MenuInflater inflater = getActivity().getMenuInflater();
446
447        final String modelId = getModelId(v);
448        if (modelId == null) {
449            // TODO: inject DirectoryDetails into MenuManager constructor
450            // Since both classes are supplied by Activity and created
451            // at the same time.
452            mInjector.menuManager.inflateContextMenuForContainer(menu, inflater);
453        } else {
454            mInjector.menuManager.inflateContextMenuForDocs(menu, inflater, mSelectionMetadata);
455        }
456    }
457
458    @Override
459    public boolean onContextItemSelected(MenuItem item) {
460        return handleMenuItemClick(item);
461    }
462
463    private void handleCopyResult(int resultCode, Intent data) {
464
465        FileOperation operation = mLocalState.claimPendingOperation();
466
467        if (resultCode == Activity.RESULT_CANCELED || data == null) {
468            // User pressed the back button or otherwise cancelled the destination pick. Don't
469            // proceed with the copy.
470            operation.dispose();
471            return;
472        }
473
474        operation.setDestination(data.getParcelableExtra(Shared.EXTRA_STACK));
475        FileOperations.start(
476                mActivity,
477                operation,
478                mInjector.dialogs::showFileOperationStatus);
479    }
480
481    protected boolean onContextMenuClick(InputEvent e) {
482        final View v;
483        final float x, y;
484        if (e.isOverModelItem()) {
485            DocumentHolder doc = (DocumentHolder) e.getDocumentDetails();
486
487            v = doc.itemView;
488            x = e.getX() - v.getLeft();
489            y = e.getY() - v.getTop();
490        } else {
491            v = mRecView;
492            x = e.getX();
493            y = e.getY();
494        }
495
496        mInjector.menuManager.showContextMenu(this, v, x, y);
497
498        return true;
499    }
500
501    public void onViewModeChanged() {
502        // Mode change is just visual change; no need to kick loader.
503        onDisplayStateChanged();
504    }
505
506    private void onDisplayStateChanged() {
507        updateLayout(mState.derivedMode);
508        mRecView.setAdapter(mAdapter);
509    }
510
511    /**
512     * Updates the layout after the view mode switches.
513     * @param mode The new view mode.
514     */
515    private void updateLayout(@ViewMode int mode) {
516        mMode = mode;
517        mColumnCount = calculateColumnCount(mode);
518        if (mLayout != null) {
519            mLayout.setSpanCount(mColumnCount);
520        }
521
522        int pad = getDirectoryPadding(mode);
523        mRecView.setPadding(pad, pad, pad, pad);
524        mRecView.requestLayout();
525        if (mBandController != null) {
526            mBandController.handleLayoutChanged();
527        }
528        mIconHelper.setViewMode(mode);
529    }
530
531    /**
532     * Updates the layout after the view mode switches.
533     * @param mode The new view mode.
534     */
535    private void scaleLayout(float scale) {
536        assert(Build.IS_DEBUGGABLE);
537        if (VERBOSE) Log.v(
538                TAG, "Handling scale event: " + scale + ", existing scale: " + mLiveScale);
539
540        if (mMode == MODE_GRID) {
541            float minScale = getFraction(R.fraction.grid_scale_min);
542            float maxScale = getFraction(R.fraction.grid_scale_max);
543            float nextScale = mLiveScale * scale;
544
545            if (VERBOSE) Log.v(TAG,
546                    "Next scale " + nextScale + ", Min/max scale " + minScale + "/" + maxScale);
547
548            if (nextScale > minScale && nextScale < maxScale) {
549                if (DEBUG) Log.d(TAG, "Updating grid scale: " + scale);
550                mLiveScale = nextScale;
551                updateLayout(mMode);
552            }
553
554        } else {
555            if (DEBUG) Log.d(TAG, "List mode, ignoring scale: " + scale);
556            mLiveScale = 1.0f;
557        }
558    }
559
560    private int calculateColumnCount(@ViewMode int mode) {
561        if (mode == MODE_LIST) {
562            // List mode is a "grid" with 1 column.
563            return 1;
564        }
565
566        int cellWidth = getScaledSize(R.dimen.grid_width);
567        int cellMargin = 2 * getScaledSize(R.dimen.grid_item_margin);
568        int viewPadding =
569                (int) ((mRecView.getPaddingLeft() + mRecView.getPaddingRight()) * mLiveScale);
570
571        // RecyclerView sometimes gets a width of 0 (see b/27150284).
572        // Clamp so that we always lay out the grid with at least 2 columns by default.
573        int columnCount = Math.max(2,
574                (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin));
575
576        // Finally with our grid count logic firmly in place, we apply any live scaling
577        // captured by the scale gesture detector.
578        return Math.max(1, Math.round(columnCount / mLiveScale));
579    }
580
581
582    /**
583     * Moderately abuse the "fraction" resource type for our purposes.
584     */
585    private float getFraction(@FractionRes int id) {
586        return getResources().getFraction(id, 1, 0);
587    }
588
589    private int getScaledSize(@DimenRes int id) {
590        return (int) (getResources().getDimensionPixelSize(id) * mLiveScale);
591    }
592
593    private int getDirectoryPadding(@ViewMode int mode) {
594        switch (mode) {
595            case MODE_GRID:
596                return getResources().getDimensionPixelSize(R.dimen.grid_container_padding);
597            case MODE_LIST:
598                return getResources().getDimensionPixelSize(R.dimen.list_container_padding);
599            default:
600                throw new IllegalArgumentException("Unsupported layout mode: " + mode);
601        }
602    }
603
604    private boolean handleMenuItemClick(MenuItem item) {
605        Selection selection = mSelectionMgr.getSelection(new Selection());
606
607        switch (item.getItemId()) {
608            case R.id.menu_open:
609                openDocuments(selection);
610                mActionModeController.finishActionMode();
611                return true;
612
613            case R.id.menu_open_with:
614                showChooserForDoc(selection);
615                return true;
616
617            case R.id.menu_open_in_new_window:
618                mActions.openSelectedInNewWindow();
619                return true;
620
621            case R.id.menu_share:
622                mActions.shareSelectedDocuments();
623                return true;
624
625            case R.id.menu_delete:
626                // deleteDocuments will end action mode if the documents are deleted.
627                // It won't end action mode if user cancels the delete.
628                mActions.deleteSelectedDocuments();
629                return true;
630
631            case R.id.menu_copy_to:
632                transferDocuments(selection, null, FileOperationService.OPERATION_COPY);
633                // TODO: Only finish selection mode if copy-to is not canceled.
634                // Need to plum down into handling the way we do with deleteDocuments.
635                mActionModeController.finishActionMode();
636                return true;
637
638            case R.id.menu_compress:
639                transferDocuments(selection, mState.stack,
640                        FileOperationService.OPERATION_COMPRESS);
641                // TODO: Only finish selection mode if compress is not canceled.
642                // Need to plum down into handling the way we do with deleteDocuments.
643                mActionModeController.finishActionMode();
644                return true;
645
646            // TODO: Implement extract (to the current directory).
647            case R.id.menu_extract_to:
648                transferDocuments(selection, null, FileOperationService.OPERATION_EXTRACT);
649                // TODO: Only finish selection mode if compress-to is not canceled.
650                // Need to plum down into handling the way we do with deleteDocuments.
651                mActionModeController.finishActionMode();
652                return true;
653
654            case R.id.menu_move_to:
655                // Exit selection mode first, so we avoid deselecting deleted documents.
656                mActionModeController.finishActionMode();
657                transferDocuments(selection, null, FileOperationService.OPERATION_MOVE);
658                return true;
659
660            case R.id.menu_cut_to_clipboard:
661                mActions.cutToClipboard();
662                return true;
663
664            case R.id.menu_copy_to_clipboard:
665                mActions.copyToClipboard();
666                return true;
667
668            case R.id.menu_paste_from_clipboard:
669                pasteFromClipboard();
670                return true;
671
672            case R.id.menu_paste_into_folder:
673                pasteIntoFolder();
674                return true;
675
676            case R.id.menu_select_all:
677                mActions.selectAllFiles();
678                return true;
679
680            case R.id.menu_rename:
681                // Exit selection mode first, so we avoid deselecting deleted
682                // (renamed) documents.
683                mActionModeController.finishActionMode();
684                renameDocuments(selection);
685                return true;
686
687            case R.id.menu_view_in_owner:
688                mActions.viewInOwner();
689                return true;
690
691            default:
692                // See if BaseActivity can handle this particular MenuItem
693                if (!mActivity.onOptionsItemSelected(item)) {
694                    if (DEBUG) Log.d(TAG, "Unhandled menu item selected: " + item);
695                    return false;
696                }
697                return true;
698        }
699    }
700
701    private boolean onAccessibilityClick(View child) {
702        DocumentDetails doc = getDocumentHolder(child);
703        mActions.openDocument(doc, ActionHandler.VIEW_TYPE_PREVIEW,
704                ActionHandler.VIEW_TYPE_REGULAR);
705        return true;
706    }
707
708    private void cancelThumbnailTask(View view) {
709        final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb);
710        if (iconThumb != null) {
711            mIconHelper.stopLoading(iconThumb);
712        }
713    }
714
715    // Support for opening multiple documents is currently exclusive to DocumentsActivity.
716    private void openDocuments(final Selection selected) {
717        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_OPEN);
718
719        // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
720        List<DocumentInfo> docs = mModel.getDocuments(selected);
721        if (docs.size() > 1) {
722            mActivity.onDocumentsPicked(docs);
723        } else {
724            mActivity.onDocumentPicked(docs.get(0));
725        }
726    }
727
728    private void showChooserForDoc(final Selection selected) {
729        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_OPEN);
730
731        assert(selected.size() == 1);
732        DocumentInfo doc =
733                DocumentInfo.fromDirectoryCursor(mModel.getItem(selected.iterator().next()));
734        mActions.showChooserForDoc(doc);
735    }
736
737    private void transferDocuments(final Selection selected, @Nullable DocumentStack destination,
738            final @OpType int mode) {
739        switch (mode) {
740            case FileOperationService.OPERATION_COPY:
741                Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_TO);
742                break;
743            case FileOperationService.OPERATION_COMPRESS:
744                Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COMPRESS);
745                break;
746            case FileOperationService.OPERATION_EXTRACT:
747                Metrics.logUserAction(getContext(), Metrics.USER_ACTION_EXTRACT_TO);
748                break;
749            case FileOperationService.OPERATION_MOVE:
750                Metrics.logUserAction(getContext(), Metrics.USER_ACTION_MOVE_TO);
751                break;
752        }
753
754        UrisSupplier srcs;
755        try {
756            ClipStore clipStorage = DocumentsApplication.getClipStore(getContext());
757            srcs = UrisSupplier.create(selected, mModel::getItemUri, clipStorage);
758        } catch (IOException e) {
759            throw new RuntimeException("Failed to create uri supplier.", e);
760        }
761
762        final DocumentInfo parent = mState.stack.peek();
763        final FileOperation operation = new FileOperation.Builder()
764                .withOpType(mode)
765                .withSrcParent(parent == null ? null : parent.derivedUri)
766                .withSrcs(srcs)
767                .build();
768
769        if (destination != null) {
770            operation.setDestination(destination);
771            FileOperations.start(
772                    mActivity,
773                    operation,
774                    mInjector.dialogs::showFileOperationStatus);
775            return;
776        }
777
778        // Pop up a dialog to pick a destination.  This is inadequate but works for now.
779        // TODO: Implement a picker that is to spec.
780        mLocalState.mPendingOperation = operation;
781        final Intent intent = new Intent(
782                Shared.ACTION_PICK_COPY_DESTINATION,
783                Uri.EMPTY,
784                getActivity(),
785                PickActivity.class);
786
787        // Set an appropriate title on the drawer when it is shown in the picker.
788        // Coupled with the fact that we auto-open the drawer for copy/move operations
789        // it should basically be the thing people see first.
790        int drawerTitleId;
791        switch (mode) {
792            case FileOperationService.OPERATION_COPY:
793                drawerTitleId = R.string.menu_copy;
794                break;
795            case FileOperationService.OPERATION_COMPRESS:
796                drawerTitleId = R.string.menu_compress;
797                break;
798            case FileOperationService.OPERATION_EXTRACT:
799                drawerTitleId = R.string.menu_extract;
800                break;
801            case FileOperationService.OPERATION_MOVE:
802                drawerTitleId = R.string.menu_move;
803                break;
804            default:
805                throw new UnsupportedOperationException("Unknown mode: " + mode);
806        }
807
808        intent.putExtra(DocumentsContract.EXTRA_PROMPT, getResources().getString(drawerTitleId));
809
810        // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
811        List<DocumentInfo> docs = mModel.getDocuments(selected);
812
813        // Determine if there is a directory in the set of documents
814        // to be copied? Why? Directory creation isn't supported by some roots
815        // (like Downloads). This informs DocumentsActivity (the "picker")
816        // to restrict available roots to just those with support.
817        intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs));
818        intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mode);
819
820        // This just identifies the type of request...we'll check it
821        // when we reveive a response.
822        startActivityForResult(intent, REQUEST_COPY_DESTINATION);
823    }
824
825    @Override
826    public void onActivityResult(@RequestCode int requestCode, int resultCode, Intent data) {
827        switch (requestCode) {
828            case REQUEST_COPY_DESTINATION:
829                handleCopyResult(resultCode, data);
830                break;
831            default:
832                throw new UnsupportedOperationException("Unknown request code: " + requestCode);
833        }
834    }
835
836    private static boolean hasDirectory(List<DocumentInfo> docs) {
837        for (DocumentInfo info : docs) {
838            if (Document.MIME_TYPE_DIR.equals(info.mimeType)) {
839                return true;
840            }
841        }
842        return false;
843    }
844
845    private void renameDocuments(Selection selected) {
846        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_RENAME);
847
848        // Batch renaming not supported
849        // Rename option is only available in menu when 1 document selected
850        assert(selected.size() == 1);
851
852        // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
853        List<DocumentInfo> docs = mModel.getDocuments(selected);
854        RenameDocumentFragment.show(getChildFragmentManager(), docs.get(0));
855    }
856
857    Model getModel(){
858        return mModel;
859    }
860
861    private boolean isDocumentEnabled(String mimeType, int flags) {
862        return mInjector.config.isDocumentEnabled(mimeType, flags, mState);
863    }
864
865    /**
866     * Paste selection files from the primary clip into the current window.
867     */
868    public void pasteFromClipboard() {
869        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD);
870        // Since we are pasting into the current window, we already have the destination in the
871        // stack. No need for a destination DocumentInfo.
872        mClipper.copyFromClipboard(
873                mState.stack,
874                mInjector.dialogs::showFileOperationStatus);
875        getActivity().invalidateOptionsMenu();
876    }
877
878    public void pasteIntoFolder() {
879        assert (mSelectionMgr.getSelection().size() == 1);
880
881        String modelId = mSelectionMgr.getSelection().iterator().next();
882        Cursor dstCursor = mModel.getItem(modelId);
883        if (dstCursor == null) {
884            Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + modelId);
885            return;
886        }
887        DocumentInfo destination = DocumentInfo.fromDirectoryCursor(dstCursor);
888        mClipper.copyFromClipboard(
889                destination,
890                mState.stack,
891                mInjector.dialogs::showFileOperationStatus);
892        getActivity().invalidateOptionsMenu();
893    }
894
895    private void setupDragAndDropOnDocumentView(View view, Cursor cursor) {
896        final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
897        if (Document.MIME_TYPE_DIR.equals(docMimeType)) {
898            // Make a directory item a drop target. Drop on non-directories and empty space
899            // is handled at the list/grid view level.
900            view.setOnDragListener(mDragHoverListener);
901        }
902    }
903
904    void dragStopped(boolean result) {
905        if (result) {
906            mSelectionMgr.clearSelection();
907        }
908    }
909
910    @Override
911    public void runOnUiThread(Runnable runnable) {
912        getActivity().runOnUiThread(runnable);
913    }
914
915    // In DirectoryFragment, we close the roots drawer right away.
916    // We also want to update the Drag Shadow to indicate whether the
917    // item is droppable or not.
918    @Override
919    public void onDragEntered(View v, Object localState) {
920        mActivity.setRootsDrawerOpen(false);
921        mActivity.getShadowBuilder()
922                .setAppearDroppable(DragAndDropHelper.canCopyTo(localState, getDestination(v)));
923        v.updateDragShadow(mActivity.getShadowBuilder());
924    }
925
926    // In DirectoryFragment, we always reset the background of the Drag Shadow once it
927    // exits.
928    @Override
929    public void onDragExited(View v, Object localState) {
930        mActivity.getShadowBuilder().resetBackground();
931        v.updateDragShadow(mActivity.getShadowBuilder());
932        if (v.getParent() == mRecView) {
933            DocumentHolder holder = getDocumentHolder(v);
934            if (holder != null) {
935                holder.resetDropHighlight();
936            }
937        }
938    }
939
940    // In DirectoryFragment, we spring loads the hovered folder.
941    @Override
942    public void onViewHovered(View view) {
943        BaseActivity activity = mActivity;
944        if (getModelId(view) != null) {
945            mActions.springOpenDirectory(getDestination(view));
946        }
947        activity.setRootsDrawerOpen(false);
948    }
949
950    boolean handleDropEvent(View v, DragEvent event) {
951        BaseActivity activity = (BaseActivity) getActivity();
952        activity.setRootsDrawerOpen(false);
953
954        ClipData clipData = event.getClipData();
955        assert (clipData != null);
956
957        assert(mClipper.getOpType(clipData) == FileOperationService.OPERATION_COPY);
958
959        if (!DragAndDropHelper.canCopyTo(event.getLocalState(), getDestination(v))) {
960            return false;
961        }
962
963        // Recognize multi-window drag and drop based on the fact that localState is not
964        // carried between processes. It will stop working when the localsState behavior
965        // is changed. The info about window should be passed in the localState then.
966        // The localState could also be null for copying from Recents in single window
967        // mode, but Recents doesn't offer this functionality (no directories).
968        Metrics.logUserAction(getContext(),
969                event.getLocalState() == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
970                        : Metrics.USER_ACTION_DRAG_N_DROP);
971
972        DocumentInfo dst = getDestination(v);
973        // If destination is already at top of stack, no need to pass it in
974        if (dst.equals(mState.stack.peek())) {
975            mClipper.copyFromClipData(
976                    mState.stack,
977                    clipData,
978                    mInjector.dialogs::showFileOperationStatus);
979        } else {
980            mClipper.copyFromClipData(
981                    dst,
982                    mState.stack,
983                    clipData,
984                    mInjector.dialogs::showFileOperationStatus);
985        }
986        return true;
987    }
988
989    DocumentInfo getDestination(View v) {
990        String id = getModelId(v);
991        if (id != null) {
992            Cursor dstCursor = mModel.getItem(id);
993            if (dstCursor == null) {
994                Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id);
995                return null;
996            }
997            return DocumentInfo.fromDirectoryCursor(dstCursor);
998        }
999
1000        if (v == mRecView) {
1001            return mState.stack.peek();
1002        }
1003
1004        return null;
1005    }
1006
1007    @Override
1008    public void setDropTargetHighlight(View v, Object localState, boolean highlight) {
1009        // Note: use exact comparison - this code is searching for views which are children of
1010        // the RecyclerView instance in the UI.
1011        if (v.getParent() == mRecView) {
1012            DocumentHolder holder = getDocumentHolder(v);
1013            if (holder != null) {
1014                if (!highlight) {
1015                    holder.resetDropHighlight();
1016                } else {
1017                    holder.setDroppableHighlight(
1018                            DragAndDropHelper.canCopyTo(localState, getDestination(v)));
1019                }
1020            }
1021        }
1022    }
1023
1024    /**
1025     * Gets the model ID for a given RecyclerView item.
1026     * @param view A View that is a document item view, or a child of a document item view.
1027     * @return The Model ID for the given document, or null if the given view is not associated with
1028     *     a document item view.
1029     */
1030    protected @Nullable String getModelId(View view) {
1031        View itemView = mRecView.findContainingItemView(view);
1032        if (itemView != null) {
1033            RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView);
1034            if (vh instanceof DocumentHolder) {
1035                return ((DocumentHolder) vh).getModelId();
1036            }
1037        }
1038        return null;
1039    }
1040
1041    private @Nullable DocumentHolder getDocumentHolder(View v) {
1042        RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v);
1043        if (vh instanceof DocumentHolder) {
1044            return (DocumentHolder) vh;
1045        }
1046        return null;
1047    }
1048
1049    // TODO: Move to activities when Model becomes activity level object.
1050    private boolean canSelect(DocumentDetails doc) {
1051        return canSetSelectionState(doc.getModelId(), true);
1052    }
1053
1054    // TODO: Move to activities when Model becomes activity level object.
1055    private boolean canSetSelectionState(String modelId, boolean nextState) {
1056        if (nextState) {
1057            // Check if an item can be selected
1058            final Cursor cursor = mModel.getItem(modelId);
1059            if (cursor == null) {
1060                Log.w(TAG, "Couldn't obtain cursor for modelId: " + modelId);
1061                return false;
1062            }
1063
1064            final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
1065            final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
1066            return mInjector.config.canSelectType(docMimeType, docFlags, mState);
1067        } else {
1068        final DocumentInfo parent = mState.stack.peek();
1069            // Right now all selected items can be deselected.
1070            return true;
1071        }
1072    }
1073
1074    public static void showDirectory(
1075            FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) {
1076        if (DEBUG) Log.d(TAG, "Showing directory: " + DocumentInfo.debugString(doc));
1077        create(fm, root, doc, anim);
1078    }
1079
1080    public static void showRecentsOpen(FragmentManager fm, int anim) {
1081        create(fm, null, null, anim);
1082    }
1083
1084    public static void create(
1085            FragmentManager fm,
1086            RootInfo root,
1087            @Nullable DocumentInfo doc,
1088            @AnimationType int anim) {
1089
1090        if (DEBUG) {
1091            if (doc == null) {
1092                Log.d(TAG, "Creating new fragment null directory");
1093            } else {
1094                Log.d(TAG, "Creating new fragment for directory: " + DocumentInfo.debugString(doc));
1095            }
1096        }
1097
1098        final Bundle args = new Bundle();
1099        args.putParcelable(Shared.EXTRA_ROOT, root);
1100        args.putParcelable(Shared.EXTRA_DOC, doc);
1101        args.putParcelable(Shared.EXTRA_SELECTION, new Selection());
1102
1103        final FragmentTransaction ft = fm.beginTransaction();
1104        AnimationView.setupAnimations(ft, anim, args);
1105
1106        final DirectoryFragment fragment = new DirectoryFragment();
1107        fragment.setArguments(args);
1108
1109        ft.replace(getFragmentId(), fragment);
1110        ft.commitAllowingStateLoss();
1111    }
1112
1113    public static @Nullable DirectoryFragment get(FragmentManager fm) {
1114        // TODO: deal with multiple directories shown at once
1115        Fragment fragment = fm.findFragmentById(getFragmentId());
1116        return fragment instanceof DirectoryFragment
1117                ? (DirectoryFragment) fragment
1118                : null;
1119    }
1120
1121    private static int getFragmentId() {
1122        return R.id.container_directory;
1123    }
1124
1125    @Override
1126    public void onRefresh() {
1127        // Remove thumbnail cache. We do this not because we're worried about stale thumbnails as it
1128        // should be covered by last modified value we store in thumbnail cache, but rather to give
1129        // the user a greater sense that contents are being reloaded.
1130        ThumbnailCache cache = DocumentsApplication.getThumbnailCache(getContext());
1131        String[] ids = mModel.getModelIds();
1132        int numOfEvicts = Math.min(ids.length, CACHE_EVICT_LIMIT);
1133        for (int i = 0; i < numOfEvicts; ++i) {
1134            cache.removeUri(mModel.getItemUri(ids[i]));
1135        }
1136
1137        final DocumentInfo doc = mState.stack.peek();
1138        mActions.refreshDocument(doc, (boolean refreshSupported) -> {
1139            if (refreshSupported) {
1140                mRefreshLayout.setRefreshing(false);
1141            } else {
1142                // If Refresh API isn't available, we will explicitly reload the loader
1143                mActions.loadDocumentsForCurrentStack();
1144            }
1145        });
1146    }
1147
1148    private final class ModelUpdateListener implements EventListener<Model.Update> {
1149
1150        @Override
1151        public void accept(Model.Update update) {
1152            if (DEBUG) Log.d(TAG, "Received model update. Loading=" + mModel.isLoading());
1153
1154            mProgressBar.setVisibility(mModel.isLoading() ? View.VISIBLE : View.GONE);
1155
1156            updateLayout(mState.derivedMode);
1157
1158            mAdapter.notifyDataSetChanged();
1159
1160            if (mRestoredSelection != null) {
1161                mSelectionMgr.restoreSelection(mRestoredSelection);
1162                // Note, we'll take care of cleaning up retained selection
1163                // in the selection handler where we already have some
1164                // specialized code to handle when selection was restored.
1165            }
1166
1167            // Restore any previous instance state
1168            final SparseArray<Parcelable> container =
1169                    mState.dirConfigs.remove(mLocalState.getConfigKey());
1170            final int curSortedDimensionId = mState.sortModel.getSortedDimensionId();
1171
1172            final SortDimension curSortedDimension =
1173                    mState.sortModel.getDimensionById(curSortedDimensionId);
1174            if (container != null
1175                    && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, false)) {
1176                getView().restoreHierarchyState(container);
1177            } else if (mLocalState.mLastSortDimensionId != curSortedDimension.getId()
1178                    || mLocalState.mLastSortDimensionId == SortModel.SORT_DIMENSION_ID_UNKNOWN
1179                    || mLocalState.mLastSortDirection != curSortedDimension.getSortDirection()) {
1180                // Scroll to the top if the sort order actually changed.
1181                mRecView.smoothScrollToPosition(0);
1182            }
1183
1184            mLocalState.mLastSortDimensionId = curSortedDimension.getId();
1185            mLocalState.mLastSortDirection = curSortedDimension.getSortDirection();
1186
1187            if (mRefreshLayout.isRefreshing()) {
1188                new Handler().postDelayed(
1189                        () -> mRefreshLayout.setRefreshing(false),
1190                        REFRESH_SPINNER_TIMEOUT);
1191            }
1192
1193            if (!mModel.isLoading()) {
1194                mActivity.notifyDirectoryLoaded(
1195                        mModel.doc != null ? mModel.doc.derivedUri : null);
1196            }
1197        }
1198    }
1199
1200    private final class AdapterEnvironment implements DocumentsAdapter.Environment {
1201
1202        @Override
1203        public Features getFeatures() {
1204            return mInjector.features;
1205        }
1206
1207        @Override
1208        public Context getContext() {
1209            return mActivity;
1210        }
1211
1212        @Override
1213        public State getDisplayState() {
1214            return mState;
1215        }
1216
1217        @Override
1218        public boolean isInSearchMode() {
1219            return mInjector.searchManager.isSearching();
1220        }
1221
1222        @Override
1223        public Model getModel() {
1224            return mModel;
1225        }
1226
1227        @Override
1228        public int getColumnCount() {
1229            return mColumnCount;
1230        }
1231
1232        @Override
1233        public boolean isSelected(String id) {
1234            return mSelectionMgr.getSelection().contains(id);
1235        }
1236
1237        @Override
1238        public boolean isDocumentEnabled(String mimeType, int flags) {
1239            return mInjector.config.isDocumentEnabled(mimeType, flags, mState);
1240        }
1241
1242        @Override
1243        public void initDocumentHolder(DocumentHolder holder) {
1244            holder.addKeyEventListener(mInputHandler);
1245            holder.itemView.setOnFocusChangeListener(mFocusManager);
1246        }
1247
1248        @Override
1249        public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) {
1250            setupDragAndDropOnDocumentView(holder.itemView, cursor);
1251        }
1252
1253        @Override
1254        public ActionHandler getActionHandler() {
1255            return mActions;
1256        }
1257    }
1258}
1259