Home | History | Annotate | Download | only in dirlist
      1 /*
      2  * Copyright (C) 2013 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.documentsui.dirlist;
     18 
     19 import static com.android.documentsui.base.DocumentInfo.getCursorString;
     20 import static com.android.documentsui.base.SharedMinimal.DEBUG;
     21 import static com.android.documentsui.base.SharedMinimal.VERBOSE;
     22 import static com.android.documentsui.base.State.MODE_GRID;
     23 import static com.android.documentsui.base.State.MODE_LIST;
     24 
     25 import android.annotation.DimenRes;
     26 import android.annotation.FractionRes;
     27 import android.annotation.IntDef;
     28 import android.app.Activity;
     29 import android.app.ActivityManager;
     30 import android.app.Fragment;
     31 import android.app.FragmentManager;
     32 import android.app.FragmentTransaction;
     33 import android.content.Context;
     34 import android.content.Intent;
     35 import android.database.Cursor;
     36 import android.net.Uri;
     37 import android.os.Build;
     38 import android.os.Bundle;
     39 import android.os.Handler;
     40 import android.os.Parcelable;
     41 import android.provider.DocumentsContract;
     42 import android.provider.DocumentsContract.Document;
     43 import android.support.annotation.Nullable;
     44 import android.support.v4.widget.SwipeRefreshLayout;
     45 import android.support.v7.widget.GridLayoutManager;
     46 import android.support.v7.widget.GridLayoutManager.SpanSizeLookup;
     47 import android.support.v7.widget.RecyclerView;
     48 import android.support.v7.widget.RecyclerView.RecyclerListener;
     49 import android.support.v7.widget.RecyclerView.ViewHolder;
     50 import android.util.Log;
     51 import android.util.SparseArray;
     52 import android.view.ContextMenu;
     53 import android.view.GestureDetector;
     54 import android.view.LayoutInflater;
     55 import android.view.MenuInflater;
     56 import android.view.MenuItem;
     57 import android.view.MotionEvent;
     58 import android.view.View;
     59 import android.view.ViewGroup;
     60 import android.widget.ImageView;
     61 
     62 import com.android.documentsui.ActionHandler;
     63 import com.android.documentsui.ActionModeController;
     64 import com.android.documentsui.BaseActivity;
     65 import com.android.documentsui.BaseActivity.RetainedState;
     66 import com.android.documentsui.DocumentsApplication;
     67 import com.android.documentsui.FocusManager;
     68 import com.android.documentsui.Injector;
     69 import com.android.documentsui.Injector.ContentScoped;
     70 import com.android.documentsui.Injector.Injected;
     71 import com.android.documentsui.Metrics;
     72 import com.android.documentsui.Model;
     73 import com.android.documentsui.R;
     74 import com.android.documentsui.ThumbnailCache;
     75 import com.android.documentsui.base.DocumentFilters;
     76 import com.android.documentsui.base.DocumentInfo;
     77 import com.android.documentsui.base.DocumentStack;
     78 import com.android.documentsui.base.EventListener;
     79 import com.android.documentsui.base.Features;
     80 import com.android.documentsui.base.RootInfo;
     81 import com.android.documentsui.base.Shared;
     82 import com.android.documentsui.base.State;
     83 import com.android.documentsui.base.State.ViewMode;
     84 import com.android.documentsui.clipping.ClipStore;
     85 import com.android.documentsui.clipping.DocumentClipper;
     86 import com.android.documentsui.clipping.UrisSupplier;
     87 import com.android.documentsui.dirlist.AnimationView.AnimationType;
     88 import com.android.documentsui.picker.PickActivity;
     89 import com.android.documentsui.selection.BandSelectionHelper;
     90 import com.android.documentsui.selection.ContentLock;
     91 import com.android.documentsui.selection.DefaultBandHost;
     92 import com.android.documentsui.selection.DefaultBandPredicate;
     93 import com.android.documentsui.selection.GestureRouter;
     94 import com.android.documentsui.selection.GestureSelectionHelper;
     95 import com.android.documentsui.selection.ItemDetailsLookup;
     96 import com.android.documentsui.selection.MotionInputHandler;
     97 import com.android.documentsui.selection.MouseInputHandler;
     98 import com.android.documentsui.selection.Selection;
     99 import com.android.documentsui.selection.SelectionHelper;
    100 import com.android.documentsui.selection.SelectionHelper.SelectionPredicate;
    101 import com.android.documentsui.selection.TouchEventRouter;
    102 import com.android.documentsui.selection.TouchInputHandler;
    103 import com.android.documentsui.services.FileOperation;
    104 import com.android.documentsui.services.FileOperationService;
    105 import com.android.documentsui.services.FileOperationService.OpType;
    106 import com.android.documentsui.services.FileOperations;
    107 import com.android.documentsui.sorting.SortDimension;
    108 import com.android.documentsui.sorting.SortModel;
    109 
    110 import java.io.IOException;
    111 import java.lang.annotation.Retention;
    112 import java.lang.annotation.RetentionPolicy;
    113 import java.util.List;
    114 
    115 /**
    116  * Display the documents inside a single directory.
    117  */
    118 public 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