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