blob: e6e1048b4f7032e5da9a13e1884419ed2f9f98ce [file] [log] [blame]
Steve McKay84d66782016-07-29 09:39:52 -07001/*
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.Shared.DEBUG;
20import static com.android.documentsui.State.MODE_GRID;
21import static com.android.documentsui.State.MODE_LIST;
22import static com.android.documentsui.State.SORT_ORDER_UNKNOWN;
23import static com.android.documentsui.model.DocumentInfo.getCursorInt;
24import static com.android.documentsui.model.DocumentInfo.getCursorString;
25
26import android.annotation.IntDef;
27import android.annotation.StringRes;
28import android.app.Activity;
29import android.app.ActivityManager;
30import android.app.AlertDialog;
31import android.app.Fragment;
32import android.app.FragmentManager;
33import android.app.FragmentTransaction;
34import android.app.LoaderManager.LoaderCallbacks;
35import android.content.ClipData;
36import android.content.Context;
37import android.content.DialogInterface;
38import android.content.Intent;
39import android.content.Loader;
40import android.database.Cursor;
41import android.graphics.drawable.Drawable;
42import android.net.Uri;
43import android.os.AsyncTask;
44import android.os.Bundle;
45import android.os.Handler;
46import android.os.Parcelable;
47import android.provider.DocumentsContract;
48import android.provider.DocumentsContract.Document;
49import android.support.v13.view.DragStartHelper;
50import android.support.v4.widget.SwipeRefreshLayout;
51import android.support.v7.widget.GridLayoutManager;
52import android.support.v7.widget.GridLayoutManager.SpanSizeLookup;
53import android.support.v7.widget.RecyclerView;
54import android.support.v7.widget.RecyclerView.RecyclerListener;
55import android.support.v7.widget.RecyclerView.ViewHolder;
56import android.text.BidiFormatter;
57import android.text.TextUtils;
58import android.util.Log;
59import android.util.SparseArray;
60import android.view.ActionMode;
61import android.view.ContextMenu;
62import android.view.DragEvent;
63import android.view.HapticFeedbackConstants;
64import android.view.LayoutInflater;
65import android.view.Menu;
66import android.view.MenuInflater;
67import android.view.MenuItem;
68import android.view.MotionEvent;
69import android.view.View;
70import android.view.ViewGroup;
71import android.widget.ImageView;
72import android.widget.TextView;
73import android.widget.Toolbar;
74
75import com.android.documentsui.BaseActivity;
76import com.android.documentsui.DirectoryLoader;
77import com.android.documentsui.DirectoryResult;
78import com.android.documentsui.DocumentsActivity;
79import com.android.documentsui.DocumentsApplication;
80import com.android.documentsui.Events.InputEvent;
81import com.android.documentsui.Events.MotionInputEvent;
82import com.android.documentsui.ItemDragListener;
83import com.android.documentsui.MenuManager;
84import com.android.documentsui.Menus;
85import com.android.documentsui.MessageBar;
86import com.android.documentsui.Metrics;
87import com.android.documentsui.MimePredicate;
88import com.android.documentsui.R;
89import com.android.documentsui.RecentsLoader;
90import com.android.documentsui.RetainedState;
91import com.android.documentsui.RootsCache;
92import com.android.documentsui.Shared;
93import com.android.documentsui.Snackbars;
94import com.android.documentsui.State;
95import com.android.documentsui.State.ViewMode;
96import com.android.documentsui.ThumbnailCache;
97import com.android.documentsui.clipping.DocumentClipper;
98import com.android.documentsui.clipping.UrisSupplier;
99import com.android.documentsui.dirlist.MultiSelectManager.Selection;
100import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
101import com.android.documentsui.model.DocumentInfo;
102import com.android.documentsui.model.RootInfo;
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;
107
108import java.io.IOException;
109import java.lang.annotation.Retention;
110import java.lang.annotation.RetentionPolicy;
111import java.util.ArrayList;
112import java.util.Collections;
113import java.util.List;
114import java.util.Objects;
115import java.util.function.Function;
116
117import javax.annotation.Nullable;
118
119/**
120 * Display the documents inside a single directory.
121 */
122public class DirectoryFragment extends Fragment
123 implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult>,
124 ItemDragListener.DragHost, SwipeRefreshLayout.OnRefreshListener {
125
126 @IntDef(flag = true, value = {
127 TYPE_NORMAL,
128 TYPE_RECENT_OPEN
129 })
130 @Retention(RetentionPolicy.SOURCE)
131 public @interface ResultType {}
132 public static final int TYPE_NORMAL = 1;
133 public static final int TYPE_RECENT_OPEN = 2;
134
135 @IntDef(flag = true, value = {
136 REQUEST_COPY_DESTINATION
137 })
138 @Retention(RetentionPolicy.SOURCE)
139 public @interface RequestCode {}
140 public static final int REQUEST_COPY_DESTINATION = 1;
141
142 private static final String TAG = "DirectoryFragment";
143 private static final int LOADER_ID = 42;
144
145 private static final int CACHE_EVICT_LIMIT = 100;
146 private static final int REFRESH_SPINNER_DISMISS_DELAY = 500;
147
148 private Model mModel;
149 private MultiSelectManager mSelectionMgr;
150 private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener();
151 private UserInputHandler<InputEvent> mInputHandler;
152 private SelectionModeListener mSelectionModeListener;
153 private FocusManager mFocusManager;
154
155 private IconHelper mIconHelper;
156
157 private SwipeRefreshLayout mRefreshLayout;
158 private View mEmptyView;
159 private RecyclerView mRecView;
160 private ListeningGestureDetector mGestureDetector;
161
162 private String mStateKey;
163
164 private int mLastSortOrder = SORT_ORDER_UNKNOWN;
165 private DocumentsAdapter mAdapter;
166 private FragmentTuner mTuner;
167 private DocumentClipper mClipper;
168 private GridLayoutManager mLayout;
169 private int mColumnCount = 1; // This will get updated when layout changes.
170
171 private LayoutInflater mInflater;
172 private MessageBar mMessageBar;
173 private View mProgressBar;
174
175 // Directory fragment state is defined by: root, document, query, type, selection
176 private @ResultType int mType = TYPE_NORMAL;
177 private RootInfo mRoot;
178 private DocumentInfo mDocument;
179 private String mQuery = null;
180 // Note, we use !null to indicate that selection was restored (from rotation).
181 // So don't fiddle with this field unless you've got the bigger picture in mind.
182 private @Nullable Selection mRestoredSelection = null;
183 // Here we save the clip details of moveTo/copyTo actions when picker shows up.
184 // This will be written to saved instance.
185 private @Nullable FileOperation mPendingOperation;
186 private boolean mSearchMode = false;
187
188 private @Nullable BandController mBandController;
189 private @Nullable ActionMode mActionMode;
190
191 private DragScrollListener mOnDragListener;
192 private MenuManager mMenuManager;
193
194 @Override
195 public View onCreateView(
196 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
197 mInflater = inflater;
198 final View view = inflater.inflate(R.layout.fragment_directory, container, false);
199
200 mMessageBar = MessageBar.create(getChildFragmentManager());
201 mProgressBar = view.findViewById(R.id.progressbar);
202
203 mRefreshLayout = (SwipeRefreshLayout) view.findViewById(R.id.refresh_layout);
204 mRefreshLayout.setOnRefreshListener(this);
205
206 mEmptyView = view.findViewById(android.R.id.empty);
207 mRecView = (RecyclerView) view.findViewById(R.id.dir_list);
208 mRecView.setRecyclerListener(
209 new RecyclerListener() {
210 @Override
211 public void onViewRecycled(ViewHolder holder) {
212 cancelThumbnailTask(holder.itemView);
213 }
214 });
215
216 mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity()));
217
Ben Lin8f4fd342016-08-01 18:05:23 -0700218 final int edgeHeight = (int) getResources().getDimension(R.dimen.autoscroll_edge_height);
Steve McKay84d66782016-07-29 09:39:52 -0700219 mOnDragListener = DragScrollListener.create(
Ben Lin8f4fd342016-08-01 18:05:23 -0700220 edgeHeight, new DirectoryDragListener(this), mRecView);
Steve McKay84d66782016-07-29 09:39:52 -0700221
222 // Make the recycler and the empty views responsive to drop events.
223 mRecView.setOnDragListener(mOnDragListener);
224 mEmptyView.setOnDragListener(mOnDragListener);
225
226 return view;
227 }
228
229 @Override
230 public void onDestroyView() {
231 mSelectionMgr.clearSelection();
232
233 // Cancel any outstanding thumbnail requests
234 final int count = mRecView.getChildCount();
235 for (int i = 0; i < count; i++) {
236 final View view = mRecView.getChildAt(i);
237 cancelThumbnailTask(view);
238 }
239
240 super.onDestroyView();
241 }
242
243 @Override
244 public void onActivityCreated(Bundle savedInstanceState) {
245 super.onActivityCreated(savedInstanceState);
246
247 final Context context = getActivity();
248 final State state = getDisplayState();
249
250 // Read arguments when object created for the first time.
251 // Restore state if fragment recreated.
252 Bundle args = savedInstanceState == null ? getArguments() : savedInstanceState;
253 mRoot = args.getParcelable(Shared.EXTRA_ROOT);
254 mDocument = args.getParcelable(Shared.EXTRA_DOC);
255 mStateKey = buildStateKey(mRoot, mDocument);
256 mQuery = args.getString(Shared.EXTRA_QUERY);
257 mType = args.getInt(Shared.EXTRA_TYPE);
258 mSearchMode = args.getBoolean(Shared.EXTRA_SEARCH_MODE);
259 mPendingOperation = args.getParcelable(FileOperationService.EXTRA_OPERATION);
260
261 // Restore any selection we may have squirreled away in retained state.
262 @Nullable RetainedState retained = getBaseActivity().getRetainedState();
263 if (retained != null && retained.hasSelection()) {
264 // We claim the selection for ourselves and null it out once used
265 // so we don't have a rando selection hanging around in RetainedState.
266 mRestoredSelection = retained.selection;
267 retained.selection = null;
268 }
269
270 mIconHelper = new IconHelper(context, MODE_GRID);
271
272 mAdapter = new SectionBreakDocumentsAdapterWrapper(
273 this, new ModelBackedDocumentsAdapter(this, mIconHelper));
274
275 mRecView.setAdapter(mAdapter);
276
277 mLayout = new GridLayoutManager(getContext(), mColumnCount);
278 SpanSizeLookup lookup = mAdapter.createSpanSizeLookup();
279 if (lookup != null) {
280 mLayout.setSpanSizeLookup(lookup);
281 }
282 mRecView.setLayoutManager(mLayout);
283
284 // TODO: instead of inserting the view into the constructor, extract listener-creation code
285 // and set the listener on the view after the fact. Then the view doesn't need to be passed
286 // into the selection manager.
287 mSelectionMgr = new MultiSelectManager(
288 mAdapter,
289 state.allowMultiple
290 ? MultiSelectManager.MODE_MULTIPLE
291 : MultiSelectManager.MODE_SINGLE);
292
293 // Make sure this is done after the RecyclerView is set up.
294 mFocusManager = new FocusManager(context, mRecView, mModel);
295
296 mInputHandler = new UserInputHandler<>(
297 mSelectionMgr,
298 mFocusManager,
299 new Function<MotionEvent, InputEvent>() {
300 @Override
301 public InputEvent apply(MotionEvent t) {
302 return MotionInputEvent.obtain(t, mRecView);
303 }
304 },
305 this::getTarget,
306 this::canSelect,
307 this::onRightClick,
308 this::onActivate,
309 (DocumentDetails ignored) -> {
310 return onDeleteSelectedDocuments();
311 });
312
313 mGestureDetector =
314 new ListeningGestureDetector(this.getContext(), mDragHelper, mInputHandler);
315
316 mRecView.addOnItemTouchListener(mGestureDetector);
317 mEmptyView.setOnTouchListener(mGestureDetector);
318
319 if (state.allowMultiple) {
320 mBandController = new BandController(mRecView, mAdapter, mSelectionMgr);
321 }
322
323 mSelectionModeListener = new SelectionModeListener();
324 mSelectionMgr.addCallback(mSelectionModeListener);
325
326 mModel = new Model();
327 mModel.addUpdateListener(mAdapter);
328 mModel.addUpdateListener(mModelUpdateListener);
329
330 final BaseActivity activity = getBaseActivity();
331 mTuner = activity.createFragmentTuner();
332 mMenuManager = activity.getMenuManager();
333 mClipper = DocumentsApplication.getDocumentClipper(getContext());
334
335 final ActivityManager am = (ActivityManager) context.getSystemService(
336 Context.ACTIVITY_SERVICE);
337 boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN);
338 mIconHelper.setThumbnailsEnabled(!svelte);
339
340 // Kick off loader at least once
341 getLoaderManager().restartLoader(LOADER_ID, null, this);
342 }
343
344 public void retainState(RetainedState state) {
345 state.selection = mSelectionMgr.getSelection(new Selection());
346 }
347
348 @Override
349 public void onSaveInstanceState(Bundle outState) {
350 super.onSaveInstanceState(outState);
351
352 outState.putInt(Shared.EXTRA_TYPE, mType);
353 outState.putParcelable(Shared.EXTRA_ROOT, mRoot);
354 outState.putParcelable(Shared.EXTRA_DOC, mDocument);
355 outState.putString(Shared.EXTRA_QUERY, mQuery);
356 outState.putBoolean(Shared.EXTRA_SEARCH_MODE, mSearchMode);
357 outState.putParcelable(FileOperationService.EXTRA_OPERATION, mPendingOperation);
358 }
359
360 @Override
361 public void onActivityResult(@RequestCode int requestCode, int resultCode, Intent data) {
362 switch (requestCode) {
363 case REQUEST_COPY_DESTINATION:
364 handleCopyResult(resultCode, data);
365 break;
366 default:
367 throw new UnsupportedOperationException("Unknown request code: " + requestCode);
368 }
369 }
370
371 @Override
372 public void onCreateContextMenu(ContextMenu menu,
373 View v,
374 ContextMenu.ContextMenuInfo menuInfo) {
375 super.onCreateContextMenu(menu, v, menuInfo);
376 MenuInflater inflater = getActivity().getMenuInflater();
377 inflater.inflate(R.menu.context_menu, menu);
378
379 menu.add(Menu.NONE, R.id.menu_create_dir, Menu.NONE, R.string.menu_create_dir);
380 menu.add(Menu.NONE, R.id.menu_delete, Menu.NONE, R.string.menu_delete);
381 menu.add(Menu.NONE, R.id.menu_rename, Menu.NONE, R.string.menu_rename);
382
383 if (v == mRecView || v == mEmptyView) {
384 mMenuManager.updateContextMenu(menu, null, getBaseActivity().getDirectoryDetails());
385 } else {
386 mMenuManager.updateContextMenu(menu, mSelectionModeListener,
387 getBaseActivity().getDirectoryDetails());
388 }
389 }
390
391 @Override
392 public boolean onContextItemSelected(MenuItem item) {
393 return handleMenuItemClick(item);
394 }
395
396 private void handleCopyResult(int resultCode, Intent data) {
397
398 FileOperation operation = mPendingOperation;
399 mPendingOperation = null;
400
401 if (resultCode == Activity.RESULT_CANCELED || data == null) {
402 // User pressed the back button or otherwise cancelled the destination pick. Don't
403 // proceed with the copy.
404 operation.dispose();
405 return;
406 }
407
408 operation.setDestination(data.getParcelableExtra(Shared.EXTRA_STACK));
409
410 BaseActivity activity = getBaseActivity();
411 FileOperations.start(activity, operation, activity.fileOpCallback);
412 }
413
414 protected boolean onRightClick(InputEvent e) {
415 if (e.getItemPosition() != RecyclerView.NO_POSITION) {
416 final DocumentHolder doc = getTarget(e);
417 if (!mSelectionMgr.getSelection().contains(doc.modelId)) {
418 mSelectionMgr.replaceSelection(Collections.singleton(doc.modelId));
419 }
420
421 // We are registering for context menu here so long-press doesn't trigger this
422 // floating context menu, and then quickly unregister right afterwards
423 registerForContextMenu(doc.itemView);
424 mRecView.showContextMenuForChild(doc.itemView,
425 e.getX() - doc.itemView.getLeft(), e.getY() - doc.itemView.getTop());
426 unregisterForContextMenu(doc.itemView);
427 return true;
428 }
429
430 // If there was no corresponding item pos, that means user right-clicked on the blank
431 // pane
432 // We would want to show different options then, and not select any item
433 // The blank pane could be the recyclerView or the emptyView, so we need to register
434 // according to whichever one is visible
435 if (mEmptyView.getVisibility() == View.VISIBLE) {
436 registerForContextMenu(mEmptyView);
437 mEmptyView.showContextMenu(e.getX(), e.getY());
438 unregisterForContextMenu(mEmptyView);
439 return true;
440 }
441
442 registerForContextMenu(mRecView);
443 mRecView.showContextMenu(e.getX(), e.getY());
444 unregisterForContextMenu(mRecView);
445 return true;
446 }
447
448 private boolean handleViewItem(String id) {
449 final Cursor cursor = mModel.getItem(id);
450
451 if (cursor == null) {
452 Log.w(TAG, "Can't view item. Can't obtain cursor for modeId" + id);
453 return false;
454 }
455
456 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
457 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
458 if (mTuner.isDocumentEnabled(docMimeType, docFlags)) {
459 final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
460 getBaseActivity().onDocumentPicked(doc, mModel);
461 mSelectionMgr.clearSelection();
462 return true;
463 }
464 return false;
465 }
466
467 @Override
468 public void onStop() {
469 super.onStop();
470
471 // Remember last scroll location
472 final SparseArray<Parcelable> container = new SparseArray<Parcelable>();
473 getView().saveHierarchyState(container);
474 final State state = getDisplayState();
475 state.dirState.put(mStateKey, container);
476 }
477
478 public void onDisplayStateChanged() {
479 updateDisplayState();
480 }
481
482 public void onSortOrderChanged() {
483 // Sort order is implemented as a sorting wrapper around directory
484 // results. So when sort order changes, we force a reload of the directory.
485 getLoaderManager().restartLoader(LOADER_ID, null, this);
486 }
487
488 public void onViewModeChanged() {
489 // Mode change is just visual change; no need to kick loader.
490 updateDisplayState();
491 }
492
493 private void updateDisplayState() {
494 State state = getDisplayState();
495 updateLayout(state.derivedMode);
496 mRecView.setAdapter(mAdapter);
497 }
498
499 /**
500 * Updates the layout after the view mode switches.
501 * @param mode The new view mode.
502 */
503 private void updateLayout(@ViewMode int mode) {
504 mColumnCount = calculateColumnCount(mode);
505 if (mLayout != null) {
506 mLayout.setSpanCount(mColumnCount);
507 }
508
509 int pad = getDirectoryPadding(mode);
510 mRecView.setPadding(pad, pad, pad, pad);
511 mRecView.requestLayout();
512 if (mBandController != null) {
513 mBandController.handleLayoutChanged();
514 }
515 mIconHelper.setViewMode(mode);
516 }
517
518 private int calculateColumnCount(@ViewMode int mode) {
519 if (mode == MODE_LIST) {
520 // List mode is a "grid" with 1 column.
521 return 1;
522 }
523
524 int cellWidth = getResources().getDimensionPixelSize(R.dimen.grid_width);
525 int cellMargin = 2 * getResources().getDimensionPixelSize(R.dimen.grid_item_margin);
526 int viewPadding = mRecView.getPaddingLeft() + mRecView.getPaddingRight();
527
528 // RecyclerView sometimes gets a width of 0 (see b/27150284). Clamp so that we always lay
529 // out the grid with at least 2 columns.
530 int columnCount = Math.max(2,
531 (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin));
532
533 return columnCount;
534 }
535
536 private int getDirectoryPadding(@ViewMode int mode) {
537 switch (mode) {
538 case MODE_GRID:
539 return getResources().getDimensionPixelSize(R.dimen.grid_container_padding);
540 case MODE_LIST:
541 return getResources().getDimensionPixelSize(R.dimen.list_container_padding);
542 default:
543 throw new IllegalArgumentException("Unsupported layout mode: " + mode);
544 }
545 }
546
547 @Override
548 public int getColumnCount() {
549 return mColumnCount;
550 }
551
552 // Support method to replace getOwner().foo() with something
553 // slightly less clumsy like: getOwner().foo().
554 private BaseActivity getBaseActivity() {
555 return (BaseActivity) getActivity();
556 }
557
558 /**
559 * Manages the integration between our ActionMode and MultiSelectManager, initiating
560 * ActionMode when there is a selection, canceling it when there is no selection,
561 * and clearing selection when action mode is explicitly exited by the user.
562 */
563 private final class SelectionModeListener implements MultiSelectManager.Callback,
564 ActionMode.Callback, MenuManager.SelectionDetails {
565
566 private Selection mSelected = new Selection();
567
568 // Partial files are files that haven't been fully downloaded.
569 private int mPartialCount = 0;
570 private int mDirectoryCount = 0;
571 private int mNoDeleteCount = 0;
572 private int mNoRenameCount = 0;
573
574 private Menu mMenu;
575
576 @Override
577 public boolean onBeforeItemStateChange(String modelId, boolean selected) {
578 if (selected) {
579 final Cursor cursor = mModel.getItem(modelId);
580 if (cursor == null) {
581 Log.w(TAG, "Can't obtain cursor for modelId: " + modelId);
582 return false;
583 }
584
585 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
586 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
587 if (!mTuner.canSelectType(docMimeType, docFlags)) {
588 return false;
589 }
590 return mTuner.canSelectType(docMimeType, docFlags);
591 }
592 return true;
593 }
594
595 @Override
596 public void onItemStateChanged(String modelId, boolean selected) {
597 final Cursor cursor = mModel.getItem(modelId);
598 if (cursor == null) {
599 Log.w(TAG, "Model returned null cursor for document: " + modelId
600 + ". Ignoring state changed event.");
601 return;
602 }
603
604 // TODO: Should this be happening in onSelectionChanged? Technically this callback is
605 // triggered on "silent" selection updates (i.e. we might be reacting to unfinalized
606 // selection changes here)
607 final String mimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
608 if (MimePredicate.isDirectoryType(mimeType)) {
609 mDirectoryCount += selected ? 1 : -1;
610 }
611
612 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
613 if ((docFlags & Document.FLAG_PARTIAL) != 0) {
614 mPartialCount += selected ? 1 : -1;
615 }
616 if ((docFlags & Document.FLAG_SUPPORTS_DELETE) == 0) {
617 mNoDeleteCount += selected ? 1 : -1;
618 }
619 if ((docFlags & Document.FLAG_SUPPORTS_RENAME) == 0) {
620 mNoRenameCount += selected ? 1 : -1;
621 }
622 }
623
624 @Override
625 public void onSelectionChanged() {
626 mSelectionMgr.getSelection(mSelected);
627 if (mSelected.size() > 0) {
628 if (DEBUG) Log.d(TAG, "Maybe starting action mode.");
629 if (mActionMode == null) {
630 if (DEBUG) Log.d(TAG, "Yeah. Starting action mode.");
631 mActionMode = getActivity().startActionMode(this);
632 }
633 updateActionMenu();
634 } else {
635 if (DEBUG) Log.d(TAG, "Finishing action mode.");
636 if (mActionMode != null) {
637 mActionMode.finish();
638 }
639 }
640
641 if (mActionMode != null) {
642 assert(!mSelected.isEmpty());
643 final String title = Shared.getQuantityString(getActivity(),
644 R.plurals.elements_selected, mSelected.size());
645 mActionMode.setTitle(title);
646 mRecView.announceForAccessibility(title);
647 }
648 }
649
650 // Called when the user exits the action mode
651 @Override
652 public void onDestroyActionMode(ActionMode mode) {
653 if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
654 mActionMode = null;
655 // clear selection
656 mSelectionMgr.clearSelection();
657 mSelected.clear();
658
659 mDirectoryCount = 0;
660 mPartialCount = 0;
661 mNoDeleteCount = 0;
662 mNoRenameCount = 0;
663
664 // Re-enable TalkBack for the toolbars, as they are no longer covered by action mode.
665 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar);
666 toolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
667
668 // This toolbar is not present in the fixed_layout
669 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById(R.id.roots_toolbar);
670 if (rootsToolbar != null) {
671 rootsToolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
672 }
673 }
674
675 @Override
676 public boolean onCreateActionMode(ActionMode mode, Menu menu) {
677 if (mRestoredSelection != null) {
678 // This is a careful little song and dance to avoid haptic feedback
679 // when selection has been restored after rotation. We're
680 // also responsible for cleaning up restored selection so the
681 // object dones't unnecessarily hang around.
682 mRestoredSelection = null;
683 } else {
684 mRecView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
685 }
686
687 int size = mSelectionMgr.getSelection().size();
688 mode.getMenuInflater().inflate(R.menu.mode_directory, menu);
689 mode.setTitle(TextUtils.formatSelectedCount(size));
690
691 if (size > 0) {
692 // Hide the toolbars if action mode is enabled, so TalkBack doesn't navigate to
693 // these controls when using linear navigation.
694 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar);
695 toolbar.setImportantForAccessibility(
696 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
697
698 // This toolbar is not present in the fixed_layout
699 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById(
700 R.id.roots_toolbar);
701 if (rootsToolbar != null) {
702 rootsToolbar.setImportantForAccessibility(
703 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
704 }
705 return true;
706 }
707
708 return false;
709 }
710
711 @Override
712 public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
713 mMenu = menu;
714 updateActionMenu();
715 return true;
716 }
717
718 @Override
719 public boolean containsDirectories() {
720 return mDirectoryCount > 0;
721 }
722
723 @Override
724 public boolean containsPartialFiles() {
725 return mPartialCount > 0;
726 }
727
728 @Override
729 public boolean canDelete() {
730 return mNoDeleteCount == 0;
731 }
732
733 @Override
734 public boolean canRename() {
735 return mNoRenameCount == 0 && mSelectionMgr.getSelection().size() == 1;
736 }
737
738 private void updateActionMenu() {
739 assert(mMenu != null);
740 mMenuManager.updateActionMenu(mMenu, this);
741 Menus.disableHiddenItems(mMenu);
742 }
743
744 @Override
745 public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
746 return handleMenuItemClick(item);
747 }
748 }
749
750 private boolean handleMenuItemClick(MenuItem item) {
751 Selection selection = mSelectionMgr.getSelection(new Selection());
752
753 switch (item.getItemId()) {
754 case R.id.menu_open:
755 openDocuments(selection);
756 mActionMode.finish();
757 return true;
758
759 case R.id.menu_share:
760 shareDocuments(selection);
761 // TODO: Only finish selection if share action is completed.
762 mActionMode.finish();
763 return true;
764
765 case R.id.menu_delete:
766 // deleteDocuments will end action mode if the documents are deleted.
767 // It won't end action mode if user cancels the delete.
768 deleteDocuments(selection);
769 return true;
770
771 case R.id.menu_copy_to:
772 transferDocuments(selection, FileOperationService.OPERATION_COPY);
773 // TODO: Only finish selection mode if copy-to is not canceled.
774 // Need to plum down into handling the way we do with deleteDocuments.
775 mActionMode.finish();
776 return true;
777
778 case R.id.menu_move_to:
779 // Exit selection mode first, so we avoid deselecting deleted documents.
780 mActionMode.finish();
781 transferDocuments(selection, FileOperationService.OPERATION_MOVE);
782 return true;
783
784 case R.id.menu_cut_to_clipboard:
785 cutSelectedToClipboard();
786 return true;
787
788 case R.id.menu_copy_to_clipboard:
789 copySelectedToClipboard();
790 return true;
791
792 case R.id.menu_paste_from_clipboard:
793 pasteFromClipboard();
794 return true;
795
796 case R.id.menu_select_all:
797 selectAllFiles();
798 return true;
799
800 case R.id.menu_rename:
801 // Exit selection mode first, so we avoid deselecting deleted
802 // (renamed) documents.
803 mActionMode.finish();
804 renameDocuments(selection);
805 return true;
806
807 default:
808 // See if BaseActivity can handle this particular MenuItem
809 if (!getBaseActivity().onOptionsItemSelected(item)) {
810 if (DEBUG) Log.d(TAG, "Unhandled menu item selected: " + item);
811 return false;
812 }
813 return true;
814 }
815 }
816
817 public final boolean onBackPressed() {
818 if (mSelectionMgr.hasSelection()) {
819 if (DEBUG) Log.d(TAG, "Clearing selection on selection manager.");
820 mSelectionMgr.clearSelection();
821 return true;
822 }
823 return false;
824 }
825
826 private void cancelThumbnailTask(View view) {
827 final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb);
828 if (iconThumb != null) {
829 mIconHelper.stopLoading(iconThumb);
830 }
831 }
832
833 private void openDocuments(final Selection selected) {
834 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_OPEN);
835
836 new GetDocumentsTask() {
837 @Override
838 void onDocumentsReady(List<DocumentInfo> docs) {
839 // TODO: Implement support in Files activity for opening multiple docs.
840 BaseActivity.get(DirectoryFragment.this).onDocumentsPicked(docs);
841 }
842 }.execute(selected);
843 }
844
845 private void shareDocuments(final Selection selected) {
846 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SHARE);
847
848 new GetDocumentsTask() {
849 @Override
850 void onDocumentsReady(List<DocumentInfo> docs) {
851 Intent intent;
852
853 // Filter out directories and virtual files - those can't be shared.
854 List<DocumentInfo> docsForSend = new ArrayList<>();
855 for (DocumentInfo doc: docs) {
856 if (!doc.isDirectory() && !doc.isVirtualDocument()) {
857 docsForSend.add(doc);
858 }
859 }
860
861 if (docsForSend.size() == 1) {
862 final DocumentInfo doc = docsForSend.get(0);
863
864 intent = new Intent(Intent.ACTION_SEND);
865 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
866 intent.addCategory(Intent.CATEGORY_DEFAULT);
867 intent.setType(doc.mimeType);
868 intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri);
869
870 } else if (docsForSend.size() > 1) {
871 intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
872 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
873 intent.addCategory(Intent.CATEGORY_DEFAULT);
874
875 final ArrayList<String> mimeTypes = new ArrayList<>();
876 final ArrayList<Uri> uris = new ArrayList<>();
877 for (DocumentInfo doc : docsForSend) {
878 mimeTypes.add(doc.mimeType);
879 uris.add(doc.derivedUri);
880 }
881
882 intent.setType(findCommonMimeType(mimeTypes));
883 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
884
885 } else {
886 return;
887 }
888
889 intent = Intent.createChooser(intent, getActivity().getText(R.string.share_via));
890 startActivity(intent);
891 }
892 }.execute(selected);
893 }
894
895 private String generateDeleteMessage(final List<DocumentInfo> docs) {
896 String message;
897 int dirsCount = 0;
898
899 for (DocumentInfo doc : docs) {
900 if (doc.isDirectory()) {
901 ++dirsCount;
902 }
903 }
904
905 if (docs.size() == 1) {
906 // Deleteing 1 file xor 1 folder in cwd
907
908 // Address b/28772371, where including user strings in message can result in
909 // broken bidirectional support.
910 String displayName = BidiFormatter.getInstance().unicodeWrap(docs.get(0).displayName);
911 message = dirsCount == 0
912 ? getActivity().getString(R.string.delete_filename_confirmation_message,
913 displayName)
914 : getActivity().getString(R.string.delete_foldername_confirmation_message,
915 displayName);
916 } else if (dirsCount == 0) {
917 // Deleting only files in cwd
918 message = Shared.getQuantityString(getActivity(),
919 R.plurals.delete_files_confirmation_message, docs.size());
920 } else if (dirsCount == docs.size()) {
921 // Deleting only folders in cwd
922 message = Shared.getQuantityString(getActivity(),
923 R.plurals.delete_folders_confirmation_message, docs.size());
924 } else {
925 // Deleting mixed items (files and folders) in cwd
926 message = Shared.getQuantityString(getActivity(),
927 R.plurals.delete_items_confirmation_message, docs.size());
928 }
929 return message;
930 }
931
932 private boolean onDeleteSelectedDocuments() {
933 if (mSelectionMgr.hasSelection()) {
934 deleteDocuments(mSelectionMgr.getSelection(new Selection()));
935 }
936 return false;
937 }
938
939 private boolean onActivate(DocumentDetails doc) {
940 // Toggle selection if we're in selection mode, othewise, view item.
941 if (mSelectionMgr.hasSelection()) {
942 mSelectionMgr.toggleSelection(doc.getModelId());
943 } else {
944 handleViewItem(doc.getModelId());
945 }
946 return true;
947 }
948
949// private boolean onSelect(DocumentDetails doc) {
950// mSelectionMgr.toggleSelection(doc.getModelId());
951// mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
952// return true;
953// }
954
955 private void deleteDocuments(final Selection selected) {
956 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_DELETE);
957
958 assert(!selected.isEmpty());
959
960 final DocumentInfo srcParent = getDisplayState().stack.peek();
961 new GetDocumentsTask() {
962 @Override
963 void onDocumentsReady(final List<DocumentInfo> docs) {
964
965 TextView message =
966 (TextView) mInflater.inflate(R.layout.dialog_delete_confirmation, null);
967 message.setText(generateDeleteMessage(docs));
968
969 // For now, we implement this dialog NOT
970 // as a fragment (which can survive rotation and have its own state),
971 // but as a simple runtime dialog. So rotating a device with an
972 // active delete dialog...results in that dialog disappearing.
973 // We can do better, but don't have cycles for it now.
974 new AlertDialog.Builder(getActivity())
975 .setView(message)
976 .setPositiveButton(
977 android.R.string.ok,
978 new DialogInterface.OnClickListener() {
979 @Override
980 public void onClick(DialogInterface dialog, int id) {
981 // Finish selection mode first which clears selection so we
982 // don't end up trying to deselect deleted documents.
983 // This is done here, rather in the onActionItemClicked
984 // so we can avoid de-selecting items in the case where
985 // the user cancels the delete.
986 if (mActionMode != null) {
987 mActionMode.finish();
988 } else {
989 Log.w(TAG, "Action mode is null before deleting documents.");
990 }
991
992 UrisSupplier srcs;
993 try {
994 srcs = UrisSupplier.create(
995 selected,
996 mModel::getItemUri,
997 getContext());
998 } catch(IOException e) {
999 throw new RuntimeException("Failed to create uri supplier.", e);
1000 }
1001
1002 FileOperation operation = new FileOperation.Builder()
1003 .withOpType(FileOperationService.OPERATION_DELETE)
1004 .withDestination(getDisplayState().stack)
1005 .withSrcs(srcs)
1006 .withSrcParent(srcParent.derivedUri)
1007 .build();
1008
1009 BaseActivity activity = getBaseActivity();
1010 FileOperations.start(activity, operation, activity.fileOpCallback);
1011 }
1012 })
1013 .setNegativeButton(android.R.string.cancel, null)
1014 .show();
1015 }
1016 }.execute(selected);
1017 }
1018
1019 private void transferDocuments(final Selection selected, final @OpType int mode) {
1020 if(mode == FileOperationService.OPERATION_COPY) {
1021 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_TO);
1022 } else if (mode == FileOperationService.OPERATION_MOVE) {
1023 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_MOVE_TO);
1024 }
1025
1026 // Pop up a dialog to pick a destination. This is inadequate but works for now.
1027 // TODO: Implement a picker that is to spec.
1028 final Intent intent = new Intent(
1029 Shared.ACTION_PICK_COPY_DESTINATION,
1030 Uri.EMPTY,
1031 getActivity(),
1032 DocumentsActivity.class);
1033
1034 UrisSupplier srcs;
1035 try {
1036 srcs = UrisSupplier.create(selected, mModel::getItemUri, getContext());
1037 } catch(IOException e) {
1038 throw new RuntimeException("Failed to create uri supplier.", e);
1039 }
1040
1041 Uri srcParent = getDisplayState().stack.peek().derivedUri;
1042 mPendingOperation = new FileOperation.Builder()
1043 .withOpType(mode)
1044 .withSrcParent(srcParent)
1045 .withSrcs(srcs)
1046 .build();
1047
1048 // Relay any config overrides bits present in the original intent.
1049 Intent original = getActivity().getIntent();
1050 if (original != null && original.hasExtra(Shared.EXTRA_PRODUCTIVITY_MODE)) {
1051 intent.putExtra(
1052 Shared.EXTRA_PRODUCTIVITY_MODE,
1053 original.getBooleanExtra(Shared.EXTRA_PRODUCTIVITY_MODE, false));
1054 }
1055
1056 // Set an appropriate title on the drawer when it is shown in the picker.
1057 // Coupled with the fact that we auto-open the drawer for copy/move operations
1058 // it should basically be the thing people see first.
1059 int drawerTitleId = mode == FileOperationService.OPERATION_MOVE
1060 ? R.string.menu_move : R.string.menu_copy;
1061 intent.putExtra(DocumentsContract.EXTRA_PROMPT, getResources().getString(drawerTitleId));
1062
1063 new GetDocumentsTask() {
1064 @Override
1065 void onDocumentsReady(List<DocumentInfo> docs) {
1066 // Determine if there is a directory in the set of documents
1067 // to be copied? Why? Directory creation isn't supported by some roots
1068 // (like Downloads). This informs DocumentsActivity (the "picker")
1069 // to restrict available roots to just those with support.
1070 intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs));
1071 intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mode);
1072
1073 // This just identifies the type of request...we'll check it
1074 // when we reveive a response.
1075 startActivityForResult(intent, REQUEST_COPY_DESTINATION);
1076 }
1077
1078 }.execute(selected);
1079 }
1080
1081 private static boolean hasDirectory(List<DocumentInfo> docs) {
1082 for (DocumentInfo info : docs) {
1083 if (Document.MIME_TYPE_DIR.equals(info.mimeType)) {
1084 return true;
1085 }
1086 }
1087 return false;
1088 }
1089
1090 private void renameDocuments(Selection selected) {
1091 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_RENAME);
1092
1093 // Batch renaming not supported
1094 // Rename option is only available in menu when 1 document selected
1095 assert(selected.size() == 1);
1096
1097 new GetDocumentsTask() {
1098 @Override
1099 void onDocumentsReady(List<DocumentInfo> docs) {
1100 RenameDocumentFragment.show(getFragmentManager(), docs.get(0));
1101 }
1102 }.execute(selected);
1103 }
1104
1105 @Override
1106 public void initDocumentHolder(DocumentHolder holder) {
1107 holder.addKeyEventListener(mInputHandler);
1108 holder.itemView.setOnFocusChangeListener(mFocusManager);
1109 }
1110
1111 @Override
1112 public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) {
1113 setupDragAndDropOnDocumentView(holder.itemView, cursor);
1114 }
1115
1116 @Override
1117 public State getDisplayState() {
1118 return getBaseActivity().getDisplayState();
1119 }
1120
1121 @Override
1122 public Model getModel() {
1123 return mModel;
1124 }
1125
1126 @Override
1127 public boolean isDocumentEnabled(String docMimeType, int docFlags) {
1128 return mTuner.isDocumentEnabled(docMimeType, docFlags);
1129 }
1130
1131 private void showEmptyDirectory() {
1132 showEmptyView(R.string.empty, R.drawable.cabinet);
1133 }
1134
1135 private void showNoResults(RootInfo root) {
1136 CharSequence msg = getContext().getResources().getText(R.string.no_results);
1137 showEmptyView(String.format(String.valueOf(msg), root.title), R.drawable.cabinet);
1138 }
1139
1140 private void showQueryError() {
1141 showEmptyView(R.string.query_error, R.drawable.hourglass);
1142 }
1143
1144 private void showEmptyView(@StringRes int id, int drawable) {
1145 showEmptyView(getContext().getResources().getText(id), drawable);
1146 }
1147
1148 private void showEmptyView(CharSequence msg, int drawable) {
1149 View content = mEmptyView.findViewById(R.id.content);
1150 TextView msgView = (TextView) mEmptyView.findViewById(R.id.message);
1151 ImageView imageView = (ImageView) mEmptyView.findViewById(R.id.artwork);
1152 msgView.setText(msg);
1153 imageView.setImageResource(drawable);
1154
1155 mEmptyView.setVisibility(View.VISIBLE);
1156 mEmptyView.requestFocus();
1157 mRecView.setVisibility(View.GONE);
1158 }
1159
1160 private void showDirectory() {
1161 mEmptyView.setVisibility(View.GONE);
1162 mRecView.setVisibility(View.VISIBLE);
1163 mRecView.requestFocus();
1164 }
1165
1166 private String findCommonMimeType(List<String> mimeTypes) {
1167 String[] commonType = mimeTypes.get(0).split("/");
1168 if (commonType.length != 2) {
1169 return "*/*";
1170 }
1171
1172 for (int i = 1; i < mimeTypes.size(); i++) {
1173 String[] type = mimeTypes.get(i).split("/");
1174 if (type.length != 2) continue;
1175
1176 if (!commonType[1].equals(type[1])) {
1177 commonType[1] = "*";
1178 }
1179
1180 if (!commonType[0].equals(type[0])) {
1181 commonType[0] = "*";
1182 commonType[1] = "*";
1183 break;
1184 }
1185 }
1186
1187 return commonType[0] + "/" + commonType[1];
1188 }
1189
1190 public void copySelectedToClipboard() {
1191 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD);
1192
1193 Selection selection = mSelectionMgr.getSelection(new Selection());
1194 if (selection.isEmpty()) {
1195 return;
1196 }
1197 mSelectionMgr.clearSelection();
1198
1199 mClipper.clipDocumentsForCopy(mModel::getItemUri, selection);
1200
1201 Snackbars.showDocumentsClipped(getActivity(), selection.size());
1202 }
1203
1204 public void cutSelectedToClipboard() {
1205 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_CUT_CLIPBOARD);
1206
1207 Selection selection = mSelectionMgr.getSelection(new Selection());
1208 if (selection.isEmpty()) {
1209 return;
1210 }
1211 mSelectionMgr.clearSelection();
1212
1213 mClipper.clipDocumentsForCut(mModel::getItemUri, selection, getDisplayState().stack.peek());
1214
1215 Snackbars.showDocumentsClipped(getActivity(), selection.size());
1216 }
1217
1218 public void pasteFromClipboard() {
1219 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD);
1220
1221 BaseActivity activity = (BaseActivity) getActivity();
1222 DocumentInfo destination = activity.getCurrentDirectory();
1223 mClipper.copyFromClipboard(
1224 destination, activity.getDisplayState().stack, activity.fileOpCallback);
1225 getActivity().invalidateOptionsMenu();
1226 }
1227
1228 public void selectAllFiles() {
1229 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SELECT_ALL);
1230
1231 // Exclude disabled files
1232 List<String> enabled = new ArrayList<String>();
1233 for (String id : mAdapter.getModelIds()) {
1234 Cursor cursor = getModel().getItem(id);
1235 if (cursor == null) {
1236 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id);
1237 continue;
1238 }
1239 String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
1240 int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
1241 if (isDocumentEnabled(docMimeType, docFlags)) {
1242 enabled.add(id);
1243 }
1244 }
1245
1246 // Only select things currently visible in the adapter.
1247 boolean changed = mSelectionMgr.setItemsSelected(enabled, true);
1248 if (changed) {
1249 updateDisplayState();
1250 }
1251 }
1252
1253 /**
1254 * Attempts to restore focus on the directory listing.
1255 */
1256 public void requestFocus() {
1257 mFocusManager.restoreLastFocus();
1258 }
1259
1260 private void setupDragAndDropOnDocumentView(View view, Cursor cursor) {
1261 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
1262 if (Document.MIME_TYPE_DIR.equals(docMimeType)) {
1263 // Make a directory item a drop target. Drop on non-directories and empty space
1264 // is handled at the list/grid view level.
1265 view.setOnDragListener(mOnDragListener);
1266 }
1267
1268 if (mTuner.dragAndDropEnabled()) {
1269 // Make all items draggable.
1270 view.setOnLongClickListener(onLongClickListener);
1271 }
1272 }
1273
1274 void dragStarted() {
1275 // When files are selected for dragging, ActionMode is started. This obscures the breadcrumb
1276 // with an ActionBar. In order to make drag and drop to the breadcrumb possible, we first
1277 // end ActionMode so the breadcrumb is visible to the user.
1278 if (mActionMode != null) {
1279 mActionMode.finish();
1280 }
1281 }
1282
1283 void dragStopped(boolean result) {
1284 if (result) {
1285 mSelectionMgr.clearSelection();
1286 }
1287 }
1288
1289 @Override
1290 public void runOnUiThread(Runnable runnable) {
1291 getActivity().runOnUiThread(runnable);
1292 }
1293
1294 /**
1295 * {@inheritDoc}
1296 *
1297 * In DirectoryFragment, we spring loads the hovered folder.
1298 */
1299 @Override
1300 public void onViewHovered(View view) {
1301 BaseActivity activity = (BaseActivity) getActivity();
1302 if (getModelId(view) != null) {
1303 activity.springOpenDirectory(getDestination(view));
1304 }
1305
1306 activity.setRootsDrawerOpen(false);
1307 }
1308
1309 boolean handleDropEvent(View v, DragEvent event) {
1310 BaseActivity activity = (BaseActivity) getActivity();
1311 activity.setRootsDrawerOpen(false);
1312
1313 ClipData clipData = event.getClipData();
1314 assert (clipData != null);
1315
1316 assert(DocumentClipper.getOpType(clipData) == FileOperationService.OPERATION_COPY);
1317
1318 // Don't copy from the cwd into the cwd. Note: this currently doesn't work for
1319 // multi-window drag, because localState isn't carried over from one process to
1320 // another.
1321 Object src = event.getLocalState();
1322 DocumentInfo dst = getDestination(v);
1323 if (Objects.equals(src, dst)) {
1324 if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring.");
1325 return false;
1326 }
1327
1328 // Recognize multi-window drag and drop based on the fact that localState is not
1329 // carried between processes. It will stop working when the localsState behavior
1330 // is changed. The info about window should be passed in the localState then.
1331 // The localState could also be null for copying from Recents in single window
1332 // mode, but Recents doesn't offer this functionality (no directories).
1333 Metrics.logUserAction(getContext(),
1334 src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
1335 : Metrics.USER_ACTION_DRAG_N_DROP);
1336
1337 mClipper.copyFromClipData(dst, getDisplayState().stack, clipData, activity.fileOpCallback);
1338 return true;
1339 }
1340
1341 private DocumentInfo getDestination(View v) {
1342 String id = getModelId(v);
1343 if (id != null) {
1344 Cursor dstCursor = mModel.getItem(id);
1345 if (dstCursor == null) {
1346 Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id);
1347 return null;
1348 }
1349 return DocumentInfo.fromDirectoryCursor(dstCursor);
1350 }
1351
1352 if (v == mRecView || v == mEmptyView) {
1353 return getDisplayState().stack.peek();
1354 }
1355
1356 return null;
1357 }
1358
1359 @Override
1360 public void setDropTargetHighlight(View v, boolean highlight) {
1361 // Note: use exact comparison - this code is searching for views which are children of
1362 // the RecyclerView instance in the UI.
1363 if (v.getParent() == mRecView) {
1364 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v);
1365 if (vh instanceof DocumentHolder) {
1366 ((DocumentHolder) vh).setHighlighted(highlight);
1367 }
1368 }
1369 }
1370
1371 private @Nullable DocumentHolder getTarget(InputEvent e) {
1372 View childView = mRecView.findChildViewUnder(e.getX(), e.getY());
1373 if (childView != null) {
1374 return (DocumentHolder) mRecView.getChildViewHolder(childView);
1375 } else {
1376 return null;
1377 }
1378 }
1379
1380 /**
1381 * Gets the model ID for a given RecyclerView item.
1382 * @param view A View that is a document item view, or a child of a document item view.
1383 * @return The Model ID for the given document, or null if the given view is not associated with
1384 * a document item view.
1385 */
1386 protected @Nullable String getModelId(View view) {
1387 View itemView = mRecView.findContainingItemView(view);
1388 if (itemView != null) {
1389 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView);
1390 if (vh instanceof DocumentHolder) {
1391 return ((DocumentHolder) vh).modelId;
1392 }
1393 }
1394 return null;
1395 }
1396
1397 /**
1398 * Abstract task providing support for loading documents *off*
1399 * the main thread. And if it isn't obvious, creating a list
1400 * of documents (especially large lists) can be pretty expensive.
1401 */
1402 private abstract class GetDocumentsTask
1403 extends AsyncTask<Selection, Void, List<DocumentInfo>> {
1404 @Override
1405 protected final List<DocumentInfo> doInBackground(Selection... selected) {
1406 return mModel.getDocuments(selected[0]);
1407 }
1408
1409 @Override
1410 protected final void onPostExecute(List<DocumentInfo> docs) {
1411 onDocumentsReady(docs);
1412 }
1413
1414 abstract void onDocumentsReady(List<DocumentInfo> docs);
1415 }
1416
1417 @Override
1418 public boolean isSelected(String modelId) {
1419 return mSelectionMgr.getSelection().contains(modelId);
1420 }
1421
1422 private final class ModelUpdateListener implements Model.UpdateListener {
1423 @Override
1424 public void onModelUpdate(Model model) {
1425 if (model.info != null || model.error != null) {
1426 mMessageBar.setInfo(model.info);
1427 mMessageBar.setError(model.error);
1428 mMessageBar.show();
1429 }
1430
1431 mProgressBar.setVisibility(model.isLoading() ? View.VISIBLE : View.GONE);
1432
1433 if (model.isEmpty()) {
1434 if (mSearchMode) {
1435 showNoResults(getDisplayState().stack.root);
1436 } else {
1437 showEmptyDirectory();
1438 }
1439 } else {
1440 showDirectory();
1441 mAdapter.notifyDataSetChanged();
1442 }
1443
1444 if (!model.isLoading()) {
1445 getBaseActivity().notifyDirectoryLoaded(
1446 model.doc != null ? model.doc.derivedUri : null);
1447 }
1448 }
1449
1450 @Override
1451 public void onModelUpdateFailed(Exception e) {
1452 showQueryError();
1453 }
1454 }
1455
1456 private Drawable getDragIcon(Selection selection) {
1457 if (selection.size() == 1) {
1458 DocumentInfo doc = getSingleSelectedDocument(selection);
1459 return mIconHelper.getDocumentIcon(getContext(), doc);
1460 }
1461 return getContext().getDrawable(com.android.internal.R.drawable.ic_doc_generic);
1462 }
1463
1464 private String getDragTitle(Selection selection) {
1465 assert (!selection.isEmpty());
1466 if (selection.size() == 1) {
1467 DocumentInfo doc = getSingleSelectedDocument(selection);
1468 return doc.displayName;
1469 }
1470
1471 return Shared.getQuantityString(getContext(), R.plurals.elements_dragged, selection.size());
1472 }
1473
1474 private DocumentInfo getSingleSelectedDocument(Selection selection) {
1475 assert (selection.size() == 1);
1476 final List<DocumentInfo> docs = mModel.getDocuments(mSelectionMgr.getSelection());
1477 assert (docs.size() == 1);
1478 return docs.get(0);
1479 }
1480
1481 private DragStartHelper.OnDragStartListener mOnDragStartListener =
1482 new DragStartHelper.OnDragStartListener() {
1483 @Override
1484 public boolean onDragStart(View v, DragStartHelper helper) {
1485 Selection selection = mSelectionMgr.getSelection();
1486
1487 if (v == null) {
1488 Log.d(TAG, "Ignoring drag event, null view");
1489 return false;
1490 }
1491 if (!isSelected(getModelId(v))) {
1492 Log.d(TAG, "Ignoring drag event, unselected view.");
1493 return false;
1494 }
1495
1496 // NOTE: Preparation of the ClipData object can require a lot of time
1497 // and ideally should be done in the background. Unfortunately
1498 // the current code layout and framework assumptions don't support
1499 // this. So for now, we could end up doing a bunch of i/o on main thread.
1500 v.startDragAndDrop(
1501 mClipper.getClipDataForDocuments(
1502 mModel::getItemUri,
1503 selection,
1504 FileOperationService.OPERATION_COPY),
1505 new DragShadowBuilder(
1506 getActivity(),
1507 getDragTitle(selection),
1508 getDragIcon(selection)),
1509 getDisplayState().stack.peek(),
1510 View.DRAG_FLAG_GLOBAL
1511 | View.DRAG_FLAG_GLOBAL_URI_READ
1512 | View.DRAG_FLAG_GLOBAL_URI_WRITE);
1513
1514 return true;
1515 }
1516 };
1517
1518
1519 private DragStartHelper mDragHelper = new DragStartHelper(null, mOnDragStartListener);
1520
1521 private View.OnLongClickListener onLongClickListener = new View.OnLongClickListener() {
1522 @Override
1523 public boolean onLongClick(View v) {
1524 return mDragHelper.onLongClick(v);
1525 }
1526 };
1527
1528 private boolean canSelect(DocumentDetails doc) {
1529 return canSelect(doc.getModelId());
1530 }
1531
1532 private boolean canSelect(String modelId) {
1533
1534 // TODO: Combine this method with onBeforeItemStateChange, as both of them are almost
1535 // the same, and responsible for the same thing (whether to select or not).
1536 final Cursor cursor = mModel.getItem(modelId);
1537 if (cursor == null) {
1538 Log.w(TAG, "Couldn't obtain cursor for modelId: " + modelId);
1539 return false;
1540 }
1541
1542 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
1543 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
1544 return mTuner.canSelectType(docMimeType, docFlags);
1545 }
1546
1547 public static void showDirectory(
1548 FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) {
1549 create(fm, TYPE_NORMAL, root, doc, null, anim);
1550 }
1551
1552 public static void showRecentsOpen(FragmentManager fm, int anim) {
1553 create(fm, TYPE_RECENT_OPEN, null, null, null, anim);
1554 }
1555
1556 public static void reloadSearch(FragmentManager fm, RootInfo root, DocumentInfo doc,
1557 String query) {
1558 DirectoryFragment df = get(fm);
1559
1560 df.mQuery = query;
1561 df.mRoot = root;
1562 df.mDocument = doc;
1563 df.mSearchMode = query != null;
1564 df.getLoaderManager().restartLoader(LOADER_ID, null, df);
1565 }
1566
1567 public static void reload(FragmentManager fm, int type, RootInfo root, DocumentInfo doc,
1568 String query) {
1569 DirectoryFragment df = get(fm);
1570 df.mType = type;
1571 df.mQuery = query;
1572 df.mRoot = root;
1573 df.mDocument = doc;
1574 df.mSearchMode = query != null;
1575 df.getLoaderManager().restartLoader(LOADER_ID, null, df);
1576 }
1577
1578 public static void create(FragmentManager fm, int type, RootInfo root, DocumentInfo doc,
1579 String query, int anim) {
1580 final Bundle args = new Bundle();
1581 args.putInt(Shared.EXTRA_TYPE, type);
1582 args.putParcelable(Shared.EXTRA_ROOT, root);
1583 args.putParcelable(Shared.EXTRA_DOC, doc);
1584 args.putString(Shared.EXTRA_QUERY, query);
1585 args.putParcelable(Shared.EXTRA_SELECTION, new Selection());
1586
1587 final FragmentTransaction ft = fm.beginTransaction();
1588 AnimationView.setupAnimations(ft, anim, args);
1589
1590 final DirectoryFragment fragment = new DirectoryFragment();
1591 fragment.setArguments(args);
1592
1593 ft.replace(getFragmentId(), fragment);
1594 ft.commitAllowingStateLoss();
1595 }
1596
1597 private static String buildStateKey(RootInfo root, DocumentInfo doc) {
1598 final StringBuilder builder = new StringBuilder();
1599 builder.append(root != null ? root.authority : "null").append(';');
1600 builder.append(root != null ? root.rootId : "null").append(';');
1601 builder.append(doc != null ? doc.documentId : "null");
1602 return builder.toString();
1603 }
1604
1605 public static @Nullable DirectoryFragment get(FragmentManager fm) {
1606 // TODO: deal with multiple directories shown at once
1607 Fragment fragment = fm.findFragmentById(getFragmentId());
1608 return fragment instanceof DirectoryFragment
1609 ? (DirectoryFragment) fragment
1610 : null;
1611 }
1612
1613 private static int getFragmentId() {
1614 return R.id.container_directory;
1615 }
1616
1617 @Override
1618 public void onRefresh() {
1619 // Remove thumbnail cache. We do this not because we're worried about stale thumbnails as it
1620 // should be covered by last modified value we store in thumbnail cache, but rather to give
1621 // the user a greater sense that contents are being reloaded.
1622 ThumbnailCache cache = DocumentsApplication.getThumbnailCache(getContext());
1623 String[] ids = mModel.getModelIds();
1624 int numOfEvicts = Math.min(ids.length, CACHE_EVICT_LIMIT);
1625 for (int i = 0; i < numOfEvicts; ++i) {
1626 cache.removeUri(mModel.getItemUri(ids[i]));
1627 }
1628
1629 // Trigger loading
1630 getLoaderManager().restartLoader(LOADER_ID, null, this);
1631 }
1632
1633 @Override
1634 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
1635 Context context = getActivity();
1636 State state = getDisplayState();
1637
1638 Uri contentsUri;
1639 switch (mType) {
1640 case TYPE_NORMAL:
1641 contentsUri = mSearchMode ? DocumentsContract.buildSearchDocumentsUri(
1642 mRoot.authority, mRoot.rootId, mQuery)
1643 : DocumentsContract.buildChildDocumentsUri(
1644 mDocument.authority, mDocument.documentId);
1645 if (mTuner.managedModeEnabled()) {
1646 contentsUri = DocumentsContract.setManageMode(contentsUri);
1647 }
1648 return new DirectoryLoader(
1649 context, mType, mRoot, mDocument, contentsUri, state.userSortOrder,
1650 mSearchMode);
1651 case TYPE_RECENT_OPEN:
1652 final RootsCache roots = DocumentsApplication.getRootsCache(context);
1653 return new RecentsLoader(context, roots, state);
1654
1655 default:
1656 throw new IllegalStateException("Unknown type " + mType);
1657 }
1658 }
1659
1660 @Override
1661 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
1662 if (!isAdded()) return;
1663
1664 if (mSearchMode) {
1665 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SEARCH);
1666 }
1667
1668 State state = getDisplayState();
1669
1670 mAdapter.notifyDataSetChanged();
1671 mModel.update(result);
1672
1673 state.derivedSortOrder = result.sortOrder;
1674
1675 updateLayout(state.derivedMode);
1676
1677 if (mRestoredSelection != null) {
1678 mSelectionMgr.restoreSelection(mRestoredSelection);
1679 // Note, we'll take care of cleaning up retained selection
1680 // in the selection handler where we already have some
1681 // specialized code to handle when selection was restored.
1682 }
1683
1684 // Restore any previous instance state
1685 final SparseArray<Parcelable> container = state.dirState.remove(mStateKey);
1686 if (container != null && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, false)) {
1687 getView().restoreHierarchyState(container);
1688 } else if (mLastSortOrder != state.derivedSortOrder) {
1689 // The derived sort order takes the user sort order into account, but applies
1690 // directory-specific defaults when the user doesn't explicitly set the sort
1691 // order. Scroll to the top if the sort order actually changed.
1692 mRecView.smoothScrollToPosition(0);
1693 }
1694
1695 mLastSortOrder = state.derivedSortOrder;
1696
1697 mTuner.onModelLoaded(mModel, mType, mSearchMode);
1698
1699 if (mRefreshLayout.isRefreshing()) {
1700 new Handler().postDelayed(
1701 () -> mRefreshLayout.setRefreshing(false),
1702 REFRESH_SPINNER_DISMISS_DELAY);
1703 }
1704 }
1705
1706 @Override
1707 public void onLoaderReset(Loader<DirectoryResult> loader) {
1708 mModel.update(null);
1709
1710 mRefreshLayout.setRefreshing(false);
1711 }
1712 }