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