1/* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.documentsui.dirlist; 18 19import static com.android.documentsui.Shared.DEBUG; 20import static com.android.documentsui.Shared.MAX_DOCS_IN_INTENT; 21import static com.android.documentsui.State.MODE_GRID; 22import static com.android.documentsui.State.MODE_LIST; 23import static com.android.documentsui.State.SORT_ORDER_UNKNOWN; 24import static com.android.documentsui.model.DocumentInfo.getCursorInt; 25import static com.android.documentsui.model.DocumentInfo.getCursorString; 26 27import android.annotation.IntDef; 28import android.annotation.StringRes; 29import android.app.Activity; 30import android.app.ActivityManager; 31import android.app.AlertDialog; 32import android.app.Fragment; 33import android.app.FragmentManager; 34import android.app.FragmentTransaction; 35import android.app.LoaderManager.LoaderCallbacks; 36import android.content.ClipData; 37import android.content.Context; 38import android.content.DialogInterface; 39import android.content.Intent; 40import android.content.Loader; 41import android.database.Cursor; 42import android.graphics.Canvas; 43import android.graphics.Point; 44import android.graphics.Rect; 45import android.graphics.drawable.Drawable; 46import android.net.Uri; 47import android.os.AsyncTask; 48import android.os.Bundle; 49import android.os.Parcel; 50import android.os.Parcelable; 51import android.provider.DocumentsContract; 52import android.provider.DocumentsContract.Document; 53import android.support.annotation.Nullable; 54import android.support.design.widget.Snackbar; 55import android.support.v13.view.DragStartHelper; 56import android.support.v7.widget.GridLayoutManager; 57import android.support.v7.widget.GridLayoutManager.SpanSizeLookup; 58import android.support.v7.widget.RecyclerView; 59import android.support.v7.widget.RecyclerView.OnItemTouchListener; 60import android.support.v7.widget.RecyclerView.RecyclerListener; 61import android.support.v7.widget.RecyclerView.ViewHolder; 62import android.text.BidiFormatter; 63import android.text.TextUtils; 64import android.util.Log; 65import android.util.SparseArray; 66import android.view.ActionMode; 67import android.view.DragEvent; 68import android.view.GestureDetector; 69import android.view.HapticFeedbackConstants; 70import android.view.KeyEvent; 71import android.view.LayoutInflater; 72import android.view.Menu; 73import android.view.MenuItem; 74import android.view.MotionEvent; 75import android.view.View; 76import android.view.ViewGroup; 77import android.widget.ImageView; 78import android.widget.TextView; 79import android.widget.Toolbar; 80 81import com.android.documentsui.BaseActivity; 82import com.android.documentsui.DirectoryLoader; 83import com.android.documentsui.DirectoryResult; 84import com.android.documentsui.DocumentClipper; 85import com.android.documentsui.DocumentsActivity; 86import com.android.documentsui.DocumentsApplication; 87import com.android.documentsui.Events; 88import com.android.documentsui.Events.MotionInputEvent; 89import com.android.documentsui.Menus; 90import com.android.documentsui.MessageBar; 91import com.android.documentsui.Metrics; 92import com.android.documentsui.MimePredicate; 93import com.android.documentsui.R; 94import com.android.documentsui.RecentsLoader; 95import com.android.documentsui.RootsCache; 96import com.android.documentsui.Shared; 97import com.android.documentsui.Snackbars; 98import com.android.documentsui.State; 99import com.android.documentsui.State.ViewMode; 100import com.android.documentsui.dirlist.MultiSelectManager.Selection; 101import com.android.documentsui.model.DocumentInfo; 102import com.android.documentsui.model.DocumentStack; 103import com.android.documentsui.model.RootInfo; 104import com.android.documentsui.services.FileOperationService; 105import com.android.documentsui.services.FileOperationService.OpType; 106import com.android.documentsui.services.FileOperations; 107 108import com.google.common.collect.Lists; 109 110import java.lang.annotation.Retention; 111import java.lang.annotation.RetentionPolicy; 112import java.util.ArrayList; 113import java.util.Collections; 114import java.util.HashSet; 115import java.util.List; 116import java.util.Objects; 117import java.util.Set; 118 119/** 120 * Display the documents inside a single directory. 121 */ 122public class DirectoryFragment extends Fragment 123 implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult> { 124 125 @IntDef(flag = true, value = { 126 TYPE_NORMAL, 127 TYPE_RECENT_OPEN 128 }) 129 @Retention(RetentionPolicy.SOURCE) 130 public @interface ResultType {} 131 public static final int TYPE_NORMAL = 1; 132 public static final int TYPE_RECENT_OPEN = 2; 133 134 @IntDef(flag = true, value = { 135 REQUEST_COPY_DESTINATION 136 }) 137 @Retention(RetentionPolicy.SOURCE) 138 public @interface RequestCode {} 139 public static final int REQUEST_COPY_DESTINATION = 1; 140 141 private static final String TAG = "DirectoryFragment"; 142 private static final int LOADER_ID = 42; 143 144 private Model mModel; 145 private MultiSelectManager mSelectionManager; 146 private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener(); 147 private ItemEventListener mItemEventListener = new ItemEventListener(); 148 private FocusManager mFocusManager; 149 150 private IconHelper mIconHelper; 151 152 private View mEmptyView; 153 private RecyclerView mRecView; 154 private ListeningGestureDetector mGestureDetector; 155 156 private String mStateKey; 157 158 private int mLastSortOrder = SORT_ORDER_UNKNOWN; 159 private DocumentsAdapter mAdapter; 160 private FragmentTuner mTuner; 161 private DocumentClipper mClipper; 162 private GridLayoutManager mLayout; 163 private int mColumnCount = 1; // This will get updated when layout changes. 164 165 private LayoutInflater mInflater; 166 private MessageBar mMessageBar; 167 private View mProgressBar; 168 169 // Directory fragment state is defined by: root, document, query, type, selection 170 private @ResultType int mType = TYPE_NORMAL; 171 private RootInfo mRoot; 172 private DocumentInfo mDocument; 173 private String mQuery = null; 174 // Save selection found during creation so it can be restored during directory loading. 175 private Selection mSelection = null; 176 private boolean mSearchMode = false; 177 private @Nullable ActionMode mActionMode; 178 179 @Override 180 public View onCreateView( 181 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 182 mInflater = inflater; 183 final View view = inflater.inflate(R.layout.fragment_directory, container, false); 184 185 mMessageBar = MessageBar.create(getChildFragmentManager()); 186 mProgressBar = view.findViewById(R.id.progressbar); 187 mEmptyView = view.findViewById(android.R.id.empty); 188 mRecView = (RecyclerView) view.findViewById(R.id.dir_list); 189 mRecView.setRecyclerListener( 190 new RecyclerListener() { 191 @Override 192 public void onViewRecycled(ViewHolder holder) { 193 cancelThumbnailTask(holder.itemView); 194 } 195 }); 196 197 mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity())); 198 199 // Make the recycler and the empty views responsive to drop events. 200 mRecView.setOnDragListener(mOnDragListener); 201 mEmptyView.setOnDragListener(mOnDragListener); 202 203 return view; 204 } 205 206 @Override 207 public void onDestroyView() { 208 mSelectionManager.clearSelection(); 209 210 // Cancel any outstanding thumbnail requests 211 final int count = mRecView.getChildCount(); 212 for (int i = 0; i < count; i++) { 213 final View view = mRecView.getChildAt(i); 214 cancelThumbnailTask(view); 215 } 216 217 super.onDestroyView(); 218 } 219 220 @Override 221 public void onActivityCreated(Bundle savedInstanceState) { 222 super.onActivityCreated(savedInstanceState); 223 224 final Context context = getActivity(); 225 final State state = getDisplayState(); 226 227 // Read arguments when object created for the first time. 228 // Restore state if fragment recreated. 229 Bundle args = savedInstanceState == null ? getArguments() : savedInstanceState; 230 mRoot = args.getParcelable(Shared.EXTRA_ROOT); 231 mDocument = args.getParcelable(Shared.EXTRA_DOC); 232 mStateKey = buildStateKey(mRoot, mDocument); 233 mQuery = args.getString(Shared.EXTRA_QUERY); 234 mType = args.getInt(Shared.EXTRA_TYPE); 235 final Selection selection = args.getParcelable(Shared.EXTRA_SELECTION); 236 mSelection = selection != null ? selection : new Selection(); 237 mSearchMode = args.getBoolean(Shared.EXTRA_SEARCH_MODE); 238 239 mIconHelper = new IconHelper(context, MODE_GRID); 240 241 mAdapter = new SectionBreakDocumentsAdapterWrapper( 242 this, new ModelBackedDocumentsAdapter(this, mIconHelper)); 243 244 mRecView.setAdapter(mAdapter); 245 246 mLayout = new GridLayoutManager(getContext(), mColumnCount); 247 SpanSizeLookup lookup = mAdapter.createSpanSizeLookup(); 248 if (lookup != null) { 249 mLayout.setSpanSizeLookup(lookup); 250 } 251 mRecView.setLayoutManager(mLayout); 252 253 mGestureDetector = 254 new ListeningGestureDetector(this.getContext(), mDragHelper, new GestureListener()); 255 256 mRecView.addOnItemTouchListener(mGestureDetector); 257 258 // TODO: instead of inserting the view into the constructor, extract listener-creation code 259 // and set the listener on the view after the fact. Then the view doesn't need to be passed 260 // into the selection manager. 261 mSelectionManager = new MultiSelectManager( 262 mRecView, 263 mAdapter, 264 state.allowMultiple 265 ? MultiSelectManager.MODE_MULTIPLE 266 : MultiSelectManager.MODE_SINGLE, 267 null); 268 269 mSelectionManager.addCallback(new SelectionModeListener()); 270 271 mModel = new Model(); 272 mModel.addUpdateListener(mAdapter); 273 mModel.addUpdateListener(mModelUpdateListener); 274 275 // Make sure this is done after the RecyclerView is set up. 276 mFocusManager = new FocusManager(context, mRecView, mModel); 277 278 mTuner = FragmentTuner.pick(getContext(), state); 279 mClipper = new DocumentClipper(context); 280 281 final ActivityManager am = (ActivityManager) context.getSystemService( 282 Context.ACTIVITY_SERVICE); 283 boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN); 284 mIconHelper.setThumbnailsEnabled(!svelte); 285 286 // Kick off loader at least once 287 getLoaderManager().restartLoader(LOADER_ID, null, this); 288 } 289 290 @Override 291 public void onSaveInstanceState(Bundle outState) { 292 super.onSaveInstanceState(outState); 293 294 mSelectionManager.getSelection(mSelection); 295 296 outState.putInt(Shared.EXTRA_TYPE, mType); 297 outState.putParcelable(Shared.EXTRA_ROOT, mRoot); 298 outState.putParcelable(Shared.EXTRA_DOC, mDocument); 299 outState.putString(Shared.EXTRA_QUERY, mQuery); 300 301 // Workaround. To avoid crash, write only up to 512 KB of selection. 302 // If more files are selected, then the selection will be lost. 303 final Parcel parcel = Parcel.obtain(); 304 try { 305 mSelection.writeToParcel(parcel, 0); 306 if (parcel.dataSize() <= 512 * 1024) { 307 outState.putParcelable(Shared.EXTRA_SELECTION, mSelection); 308 } 309 } finally { 310 parcel.recycle(); 311 } 312 313 outState.putBoolean(Shared.EXTRA_SEARCH_MODE, mSearchMode); 314 } 315 316 @Override 317 public void onActivityResult(@RequestCode int requestCode, int resultCode, Intent data) { 318 switch (requestCode) { 319 case REQUEST_COPY_DESTINATION: 320 handleCopyResult(resultCode, data); 321 break; 322 default: 323 throw new UnsupportedOperationException("Unknown request code: " + requestCode); 324 } 325 } 326 327 private void handleCopyResult(int resultCode, Intent data) { 328 if (resultCode == Activity.RESULT_CANCELED || data == null) { 329 // User pressed the back button or otherwise cancelled the destination pick. Don't 330 // proceed with the copy. 331 return; 332 } 333 334 @OpType int operationType = data.getIntExtra( 335 FileOperationService.EXTRA_OPERATION, 336 FileOperationService.OPERATION_COPY); 337 338 FileOperations.start( 339 getActivity(), 340 getDisplayState().selectedDocumentsForCopy, 341 getDisplayState().stack.peek(), 342 (DocumentStack) data.getParcelableExtra(Shared.EXTRA_STACK), 343 operationType); 344 } 345 346 protected boolean onDoubleTap(MotionEvent e) { 347 if (Events.isMouseEvent(e)) { 348 String id = getModelId(e); 349 if (id != null) { 350 return handleViewItem(id); 351 } 352 } 353 return false; 354 } 355 356 private boolean handleViewItem(String id) { 357 final Cursor cursor = mModel.getItem(id); 358 359 if (cursor == null) { 360 Log.w(TAG, "Can't view item. Can't obtain cursor for modeId" + id); 361 return false; 362 } 363 364 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 365 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 366 if (mTuner.isDocumentEnabled(docMimeType, docFlags)) { 367 final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor); 368 ((BaseActivity) getActivity()).onDocumentPicked(doc, mModel); 369 mSelectionManager.clearSelection(); 370 return true; 371 } 372 return false; 373 } 374 375 @Override 376 public void onStop() { 377 super.onStop(); 378 379 // Remember last scroll location 380 final SparseArray<Parcelable> container = new SparseArray<Parcelable>(); 381 getView().saveHierarchyState(container); 382 final State state = getDisplayState(); 383 state.dirState.put(mStateKey, container); 384 } 385 386 public void onDisplayStateChanged() { 387 updateDisplayState(); 388 } 389 390 public void onSortOrderChanged() { 391 // Sort order is implemented as a sorting wrapper around directory 392 // results. So when sort order changes, we force a reload of the directory. 393 getLoaderManager().restartLoader(LOADER_ID, null, this); 394 } 395 396 public void onViewModeChanged() { 397 // Mode change is just visual change; no need to kick loader. 398 updateDisplayState(); 399 } 400 401 private void updateDisplayState() { 402 State state = getDisplayState(); 403 updateLayout(state.derivedMode); 404 mRecView.setAdapter(mAdapter); 405 } 406 407 /** 408 * Updates the layout after the view mode switches. 409 * @param mode The new view mode. 410 */ 411 private void updateLayout(@ViewMode int mode) { 412 mColumnCount = calculateColumnCount(mode); 413 if (mLayout != null) { 414 mLayout.setSpanCount(mColumnCount); 415 } 416 417 int pad = getDirectoryPadding(mode); 418 mRecView.setPadding(pad, pad, pad, pad); 419 mRecView.requestLayout(); 420 mSelectionManager.handleLayoutChanged(); // RecyclerView doesn't do this for us 421 mIconHelper.setViewMode(mode); 422 } 423 424 private int calculateColumnCount(@ViewMode int mode) { 425 if (mode == MODE_LIST) { 426 // List mode is a "grid" with 1 column. 427 return 1; 428 } 429 430 int cellWidth = getResources().getDimensionPixelSize(R.dimen.grid_width); 431 int cellMargin = 2 * getResources().getDimensionPixelSize(R.dimen.grid_item_margin); 432 int viewPadding = mRecView.getPaddingLeft() + mRecView.getPaddingRight(); 433 434 // RecyclerView sometimes gets a width of 0 (see b/27150284). Clamp so that we always lay 435 // out the grid with at least 2 columns. 436 int columnCount = Math.max(2, 437 (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin)); 438 439 return columnCount; 440 } 441 442 private int getDirectoryPadding(@ViewMode int mode) { 443 switch (mode) { 444 case MODE_GRID: 445 return getResources().getDimensionPixelSize(R.dimen.grid_container_padding); 446 case MODE_LIST: 447 return getResources().getDimensionPixelSize(R.dimen.list_container_padding); 448 default: 449 throw new IllegalArgumentException("Unsupported layout mode: " + mode); 450 } 451 } 452 453 @Override 454 public int getColumnCount() { 455 return mColumnCount; 456 } 457 458 /** 459 * Manages the integration between our ActionMode and MultiSelectManager, initiating 460 * ActionMode when there is a selection, canceling it when there is no selection, 461 * and clearing selection when action mode is explicitly exited by the user. 462 */ 463 private final class SelectionModeListener implements MultiSelectManager.Callback, 464 ActionMode.Callback, FragmentTuner.SelectionDetails { 465 466 private Selection mSelected = new Selection(); 467 468 // Partial files are files that haven't been fully downloaded. 469 private int mPartialCount = 0; 470 private int mDirectoryCount = 0; 471 private int mNoDeleteCount = 0; 472 private int mNoRenameCount = 0; 473 474 private Menu mMenu; 475 476 @Override 477 public boolean onBeforeItemStateChange(String modelId, boolean selected) { 478 if (selected) { 479 final Cursor cursor = mModel.getItem(modelId); 480 if (cursor == null) { 481 Log.w(TAG, "Can't obtain cursor for modelId: " + modelId); 482 return false; 483 } 484 485 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 486 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 487 if (!mTuner.canSelectType(docMimeType, docFlags)) { 488 return false; 489 } 490 491 if (mSelected.size() >= MAX_DOCS_IN_INTENT) { 492 Snackbars.makeSnackbar( 493 getActivity(), 494 R.string.too_many_selected, 495 Snackbar.LENGTH_SHORT) 496 .show(); 497 return false; 498 } 499 } 500 return true; 501 } 502 503 @Override 504 public void onItemStateChanged(String modelId, boolean selected) { 505 final Cursor cursor = mModel.getItem(modelId); 506 if (cursor == null) { 507 Log.w(TAG, "Model returned null cursor for document: " + modelId 508 + ". Ignoring state changed event."); 509 return; 510 } 511 512 // TODO: Should this be happening in onSelectionChanged? Technically this callback is 513 // triggered on "silent" selection updates (i.e. we might be reacting to unfinalized 514 // selection changes here) 515 final String mimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 516 if (MimePredicate.isDirectoryType(mimeType)) { 517 mDirectoryCount += selected ? 1 : -1; 518 } 519 520 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 521 if ((docFlags & Document.FLAG_PARTIAL) != 0) { 522 mPartialCount += selected ? 1 : -1; 523 } 524 if ((docFlags & Document.FLAG_SUPPORTS_DELETE) == 0) { 525 mNoDeleteCount += selected ? 1 : -1; 526 } 527 if ((docFlags & Document.FLAG_SUPPORTS_RENAME) == 0) { 528 mNoRenameCount += selected ? 1 : -1; 529 } 530 } 531 532 @Override 533 public void onSelectionChanged() { 534 mSelectionManager.getSelection(mSelected); 535 if (mSelected.size() > 0) { 536 if (DEBUG) Log.d(TAG, "Maybe starting action mode."); 537 if (mActionMode == null) { 538 if (DEBUG) Log.d(TAG, "Yeah. Starting action mode."); 539 mActionMode = getActivity().startActionMode(this); 540 } 541 updateActionMenu(); 542 } else { 543 if (DEBUG) Log.d(TAG, "Finishing action mode."); 544 if (mActionMode != null) { 545 mActionMode.finish(); 546 } 547 } 548 549 if (mActionMode != null) { 550 assert(!mSelected.isEmpty()); 551 final String title = Shared.getQuantityString(getActivity(), 552 R.plurals.elements_selected, mSelected.size()); 553 mActionMode.setTitle(title); 554 mRecView.announceForAccessibility(title); 555 } 556 } 557 558 // Called when the user exits the action mode 559 @Override 560 public void onDestroyActionMode(ActionMode mode) { 561 if (DEBUG) Log.d(TAG, "Handling action mode destroyed."); 562 mActionMode = null; 563 // clear selection 564 mSelectionManager.clearSelection(); 565 mSelected.clear(); 566 567 mDirectoryCount = 0; 568 mPartialCount = 0; 569 mNoDeleteCount = 0; 570 mNoRenameCount = 0; 571 572 // Re-enable TalkBack for the toolbars, as they are no longer covered by action mode. 573 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar); 574 toolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 575 576 // This toolbar is not present in the fixed_layout 577 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById(R.id.roots_toolbar); 578 if (rootsToolbar != null) { 579 rootsToolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 580 } 581 } 582 583 @Override 584 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 585 mRecView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 586 587 int size = mSelectionManager.getSelection().size(); 588 mode.getMenuInflater().inflate(R.menu.mode_directory, menu); 589 mode.setTitle(TextUtils.formatSelectedCount(size)); 590 591 if (size > 0) { 592 // Hide the toolbars if action mode is enabled, so TalkBack doesn't navigate to 593 // these controls when using linear navigation. 594 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar); 595 toolbar.setImportantForAccessibility( 596 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 597 598 // This toolbar is not present in the fixed_layout 599 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById( 600 R.id.roots_toolbar); 601 if (rootsToolbar != null) { 602 rootsToolbar.setImportantForAccessibility( 603 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 604 } 605 return true; 606 } 607 608 return false; 609 } 610 611 @Override 612 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 613 mMenu = menu; 614 updateActionMenu(); 615 return true; 616 } 617 618 @Override 619 public boolean containsDirectories() { 620 return mDirectoryCount > 0; 621 } 622 623 @Override 624 public boolean containsPartialFiles() { 625 return mPartialCount > 0; 626 } 627 628 @Override 629 public boolean canDelete() { 630 return mNoDeleteCount == 0; 631 } 632 633 @Override 634 public boolean canRename() { 635 return mNoRenameCount == 0 && mSelectionManager.getSelection().size() == 1; 636 } 637 638 private void updateActionMenu() { 639 assert(mMenu != null); 640 mTuner.updateActionMenu(mMenu, this); 641 Menus.disableHiddenItems(mMenu); 642 } 643 644 @Override 645 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 646 Selection selection = mSelectionManager.getSelection(new Selection()); 647 648 switch (item.getItemId()) { 649 case R.id.menu_open: 650 openDocuments(selection); 651 mode.finish(); 652 return true; 653 654 case R.id.menu_share: 655 shareDocuments(selection); 656 // TODO: Only finish selection if share action is completed. 657 mode.finish(); 658 return true; 659 660 case R.id.menu_delete: 661 // deleteDocuments will end action mode if the documents are deleted. 662 // It won't end action mode if user cancels the delete. 663 deleteDocuments(selection); 664 return true; 665 666 case R.id.menu_copy_to: 667 transferDocuments(selection, FileOperationService.OPERATION_COPY); 668 // TODO: Only finish selection mode if copy-to is not canceled. 669 // Need to plum down into handling the way we do with deleteDocuments. 670 mode.finish(); 671 return true; 672 673 case R.id.menu_move_to: 674 // Exit selection mode first, so we avoid deselecting deleted documents. 675 mode.finish(); 676 transferDocuments(selection, FileOperationService.OPERATION_MOVE); 677 return true; 678 679 case R.id.menu_copy_to_clipboard: 680 copySelectedToClipboard(); 681 return true; 682 683 case R.id.menu_select_all: 684 selectAllFiles(); 685 return true; 686 687 case R.id.menu_rename: 688 // Exit selection mode first, so we avoid deselecting deleted 689 // (renamed) documents. 690 mode.finish(); 691 renameDocuments(selection); 692 return true; 693 694 default: 695 if (DEBUG) Log.d(TAG, "Unhandled menu item selected: " + item); 696 return false; 697 } 698 } 699 } 700 701 public final boolean onBackPressed() { 702 if (mSelectionManager.hasSelection()) { 703 if (DEBUG) Log.d(TAG, "Clearing selection on selection manager."); 704 mSelectionManager.clearSelection(); 705 return true; 706 } 707 return false; 708 } 709 710 private void cancelThumbnailTask(View view) { 711 final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb); 712 if (iconThumb != null) { 713 mIconHelper.stopLoading(iconThumb); 714 } 715 } 716 717 private void openDocuments(final Selection selected) { 718 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_OPEN); 719 720 new GetDocumentsTask() { 721 @Override 722 void onDocumentsReady(List<DocumentInfo> docs) { 723 // TODO: Implement support in Files activity for opening multiple docs. 724 BaseActivity.get(DirectoryFragment.this).onDocumentsPicked(docs); 725 } 726 }.execute(selected); 727 } 728 729 private void shareDocuments(final Selection selected) { 730 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SHARE); 731 732 new GetDocumentsTask() { 733 @Override 734 void onDocumentsReady(List<DocumentInfo> docs) { 735 Intent intent; 736 737 // Filter out directories and virtual files - those can't be shared. 738 List<DocumentInfo> docsForSend = new ArrayList<>(); 739 for (DocumentInfo doc: docs) { 740 if (!doc.isDirectory() && !doc.isVirtualDocument()) { 741 docsForSend.add(doc); 742 } 743 } 744 745 if (docsForSend.size() == 1) { 746 final DocumentInfo doc = docsForSend.get(0); 747 748 intent = new Intent(Intent.ACTION_SEND); 749 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 750 intent.addCategory(Intent.CATEGORY_DEFAULT); 751 intent.setType(doc.mimeType); 752 intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri); 753 754 } else if (docsForSend.size() > 1) { 755 intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 756 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 757 intent.addCategory(Intent.CATEGORY_DEFAULT); 758 759 final ArrayList<String> mimeTypes = new ArrayList<>(); 760 final ArrayList<Uri> uris = new ArrayList<>(); 761 for (DocumentInfo doc : docsForSend) { 762 mimeTypes.add(doc.mimeType); 763 uris.add(doc.derivedUri); 764 } 765 766 intent.setType(findCommonMimeType(mimeTypes)); 767 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); 768 769 } else { 770 return; 771 } 772 773 intent = Intent.createChooser(intent, getActivity().getText(R.string.share_via)); 774 startActivity(intent); 775 } 776 }.execute(selected); 777 } 778 779 private String generateDeleteMessage(final List<DocumentInfo> docs) { 780 String message; 781 int dirsCount = 0; 782 783 for (DocumentInfo doc : docs) { 784 if (doc.isDirectory()) { 785 ++dirsCount; 786 } 787 } 788 789 if (docs.size() == 1) { 790 // Deleteing 1 file xor 1 folder in cwd 791 792 // Address b/28772371, where including user strings in message can result in 793 // broken bidirectional support. 794 String displayName = BidiFormatter.getInstance().unicodeWrap(docs.get(0).displayName); 795 message = dirsCount == 0 796 ? getActivity().getString(R.string.delete_filename_confirmation_message, 797 displayName) 798 : getActivity().getString(R.string.delete_foldername_confirmation_message, 799 displayName); 800 } else if (dirsCount == 0) { 801 // Deleting only files in cwd 802 message = Shared.getQuantityString(getActivity(), 803 R.plurals.delete_files_confirmation_message, docs.size()); 804 } else if (dirsCount == docs.size()) { 805 // Deleting only folders in cwd 806 message = Shared.getQuantityString(getActivity(), 807 R.plurals.delete_folders_confirmation_message, docs.size()); 808 } else { 809 // Deleting mixed items (files and folders) in cwd 810 message = Shared.getQuantityString(getActivity(), 811 R.plurals.delete_items_confirmation_message, docs.size()); 812 } 813 return message; 814 } 815 816 private void deleteDocuments(final Selection selected) { 817 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_DELETE); 818 819 assert(!selected.isEmpty()); 820 821 final DocumentInfo srcParent = getDisplayState().stack.peek(); 822 new GetDocumentsTask() { 823 @Override 824 void onDocumentsReady(final List<DocumentInfo> docs) { 825 826 TextView message = 827 (TextView) mInflater.inflate(R.layout.dialog_delete_confirmation, null); 828 message.setText(generateDeleteMessage(docs)); 829 830 // This "insta-hides" files that are being deleted, because 831 // the delete operation may be not execute immediately (it 832 // may be queued up on the FileOperationService.) 833 // To hide the files locally, we call the hide method on the adapter 834 // ...which a live object...cannot be parceled. 835 // For that reason, for now, we implement this dialog NOT 836 // as a fragment (which can survive rotation and have its own state), 837 // but as a simple runtime dialog. So rotating a device with an 838 // active delete dialog...results in that dialog disappearing. 839 // We can do better, but don't have cycles for it now. 840 new AlertDialog.Builder(getActivity()) 841 .setView(message) 842 .setPositiveButton( 843 android.R.string.yes, 844 new DialogInterface.OnClickListener() { 845 public void onClick(DialogInterface dialog, int id) { 846 // Finish selection mode first which clears selection so we 847 // don't end up trying to deselect deleted documents. 848 // This is done here, rather in the onActionItemClicked 849 // so we can avoid de-selecting items in the case where 850 // the user cancels the delete. 851 if (mActionMode != null) { 852 mActionMode.finish(); 853 } else { 854 Log.w(TAG, "Action mode is null before deleting documents."); 855 } 856 // Hide the files in the UI...since the operation 857 // might be queued up on FileOperationService. 858 // We're walking a line here. 859 mAdapter.hide(selected.getAll()); 860 FileOperations.delete( 861 getActivity(), docs, srcParent, getDisplayState().stack); 862 } 863 }) 864 .setNegativeButton(android.R.string.no, null) 865 .show(); 866 } 867 }.execute(selected); 868 } 869 870 private void transferDocuments(final Selection selected, final @OpType int mode) { 871 if(mode == FileOperationService.OPERATION_COPY) { 872 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_TO); 873 } else if (mode == FileOperationService.OPERATION_MOVE) { 874 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_MOVE_TO); 875 } 876 877 // Pop up a dialog to pick a destination. This is inadequate but works for now. 878 // TODO: Implement a picker that is to spec. 879 final Intent intent = new Intent( 880 Shared.ACTION_PICK_COPY_DESTINATION, 881 Uri.EMPTY, 882 getActivity(), 883 DocumentsActivity.class); 884 885 886 // Relay any config overrides bits present in the original intent. 887 Intent original = getActivity().getIntent(); 888 if (original != null && original.hasExtra(Shared.EXTRA_PRODUCTIVITY_MODE)) { 889 intent.putExtra( 890 Shared.EXTRA_PRODUCTIVITY_MODE, 891 original.getBooleanExtra(Shared.EXTRA_PRODUCTIVITY_MODE, false)); 892 } 893 894 // Set an appropriate title on the drawer when it is shown in the picker. 895 // Coupled with the fact that we auto-open the drawer for copy/move operations 896 // it should basically be the thing people see first. 897 int drawerTitleId = mode == FileOperationService.OPERATION_MOVE 898 ? R.string.menu_move : R.string.menu_copy; 899 intent.putExtra(DocumentsContract.EXTRA_PROMPT, getResources().getString(drawerTitleId)); 900 901 new GetDocumentsTask() { 902 @Override 903 void onDocumentsReady(List<DocumentInfo> docs) { 904 // TODO: Can this move to Fragment bundle state? 905 getDisplayState().selectedDocumentsForCopy = docs; 906 907 // Determine if there is a directory in the set of documents 908 // to be copied? Why? Directory creation isn't supported by some roots 909 // (like Downloads). This informs DocumentsActivity (the "picker") 910 // to restrict available roots to just those with support. 911 intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs)); 912 intent.putExtra(FileOperationService.EXTRA_OPERATION, mode); 913 914 // This just identifies the type of request...we'll check it 915 // when we reveive a response. 916 startActivityForResult(intent, REQUEST_COPY_DESTINATION); 917 } 918 919 }.execute(selected); 920 } 921 922 private static boolean hasDirectory(List<DocumentInfo> docs) { 923 for (DocumentInfo info : docs) { 924 if (Document.MIME_TYPE_DIR.equals(info.mimeType)) { 925 return true; 926 } 927 } 928 return false; 929 } 930 931 private void renameDocuments(Selection selected) { 932 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_RENAME); 933 934 // Batch renaming not supported 935 // Rename option is only available in menu when 1 document selected 936 assert(selected.size() == 1); 937 938 new GetDocumentsTask() { 939 @Override 940 void onDocumentsReady(List<DocumentInfo> docs) { 941 RenameDocumentFragment.show(getFragmentManager(), docs.get(0)); 942 } 943 }.execute(selected); 944 } 945 946 @Override 947 public void initDocumentHolder(DocumentHolder holder) { 948 holder.addEventListener(mItemEventListener); 949 holder.itemView.setOnFocusChangeListener(mFocusManager); 950 } 951 952 @Override 953 public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) { 954 setupDragAndDropOnDocumentView(holder.itemView, cursor); 955 } 956 957 @Override 958 public State getDisplayState() { 959 return ((BaseActivity) getActivity()).getDisplayState(); 960 } 961 962 @Override 963 public Model getModel() { 964 return mModel; 965 } 966 967 @Override 968 public boolean isDocumentEnabled(String docMimeType, int docFlags) { 969 return mTuner.isDocumentEnabled(docMimeType, docFlags); 970 } 971 972 private void showEmptyDirectory() { 973 showEmptyView(R.string.empty, R.drawable.cabinet); 974 } 975 976 private void showNoResults(RootInfo root) { 977 CharSequence msg = getContext().getResources().getText(R.string.no_results); 978 showEmptyView(String.format(String.valueOf(msg), root.title), R.drawable.cabinet); 979 } 980 981 private void showQueryError() { 982 showEmptyView(R.string.query_error, R.drawable.hourglass); 983 } 984 985 private void showEmptyView(@StringRes int id, int drawable) { 986 showEmptyView(getContext().getResources().getText(id), drawable); 987 } 988 989 private void showEmptyView(CharSequence msg, int drawable) { 990 View content = mEmptyView.findViewById(R.id.content); 991 TextView msgView = (TextView) mEmptyView.findViewById(R.id.message); 992 ImageView imageView = (ImageView) mEmptyView.findViewById(R.id.artwork); 993 msgView.setText(msg); 994 imageView.setImageResource(drawable); 995 996 mEmptyView.setVisibility(View.VISIBLE); 997 mEmptyView.requestFocus(); 998 mRecView.setVisibility(View.GONE); 999 } 1000 1001 private void showDirectory() { 1002 mEmptyView.setVisibility(View.GONE); 1003 mRecView.setVisibility(View.VISIBLE); 1004 mRecView.requestFocus(); 1005 } 1006 1007 private String findCommonMimeType(List<String> mimeTypes) { 1008 String[] commonType = mimeTypes.get(0).split("/"); 1009 if (commonType.length != 2) { 1010 return "*/*"; 1011 } 1012 1013 for (int i = 1; i < mimeTypes.size(); i++) { 1014 String[] type = mimeTypes.get(i).split("/"); 1015 if (type.length != 2) continue; 1016 1017 if (!commonType[1].equals(type[1])) { 1018 commonType[1] = "*"; 1019 } 1020 1021 if (!commonType[0].equals(type[0])) { 1022 commonType[0] = "*"; 1023 commonType[1] = "*"; 1024 break; 1025 } 1026 } 1027 1028 return commonType[0] + "/" + commonType[1]; 1029 } 1030 1031 private void copyFromClipboard() { 1032 new AsyncTask<Void, Void, List<DocumentInfo>>() { 1033 1034 @Override 1035 protected List<DocumentInfo> doInBackground(Void... params) { 1036 return mClipper.getClippedDocuments(); 1037 } 1038 1039 @Override 1040 protected void onPostExecute(List<DocumentInfo> docs) { 1041 DocumentInfo destination = 1042 ((BaseActivity) getActivity()).getCurrentDirectory(); 1043 copyDocuments(docs, destination); 1044 } 1045 }.execute(); 1046 } 1047 1048 private void copyFromClipData(final ClipData clipData, final DocumentInfo destination) { 1049 assert(clipData != null); 1050 1051 new AsyncTask<Void, Void, List<DocumentInfo>>() { 1052 1053 @Override 1054 protected List<DocumentInfo> doInBackground(Void... params) { 1055 return mClipper.getDocumentsFromClipData(clipData); 1056 } 1057 1058 @Override 1059 protected void onPostExecute(List<DocumentInfo> docs) { 1060 copyDocuments(docs, destination); 1061 } 1062 }.execute(); 1063 } 1064 1065 private void copyDocuments(final List<DocumentInfo> docs, final DocumentInfo destination) { 1066 BaseActivity activity = (BaseActivity) getActivity(); 1067 if (!canCopy(docs, activity.getCurrentRoot(), destination)) { 1068 Snackbars.makeSnackbar( 1069 getActivity(), 1070 R.string.clipboard_files_cannot_paste, 1071 Snackbar.LENGTH_SHORT) 1072 .show(); 1073 return; 1074 } 1075 1076 if (docs.isEmpty()) { 1077 return; 1078 } 1079 1080 final DocumentStack curStack = getDisplayState().stack; 1081 DocumentStack tmpStack = new DocumentStack(); 1082 if (destination != null) { 1083 tmpStack.push(destination); 1084 tmpStack.addAll(curStack); 1085 } else { 1086 tmpStack = curStack; 1087 } 1088 1089 FileOperations.copy(getActivity(), docs, tmpStack); 1090 } 1091 1092 public void copySelectedToClipboard() { 1093 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD); 1094 1095 Selection selection = mSelectionManager.getSelection(new Selection()); 1096 if (!selection.isEmpty()) { 1097 copySelectionToClipboard(selection); 1098 mSelectionManager.clearSelection(); 1099 } 1100 } 1101 1102 void copySelectionToClipboard(Selection selection) { 1103 assert(!selection.isEmpty()); 1104 new GetDocumentsTask() { 1105 @Override 1106 void onDocumentsReady(List<DocumentInfo> docs) { 1107 mClipper.clipDocuments(docs); 1108 Activity activity = getActivity(); 1109 Snackbars.makeSnackbar(activity, 1110 activity.getResources().getQuantityString( 1111 R.plurals.clipboard_files_clipped, docs.size(), docs.size()), 1112 Snackbar.LENGTH_SHORT).show(); 1113 } 1114 }.execute(selection); 1115 } 1116 1117 public void pasteFromClipboard() { 1118 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD); 1119 1120 copyFromClipboard(); 1121 getActivity().invalidateOptionsMenu(); 1122 } 1123 1124 /** 1125 * Returns true if the list of files can be copied to destination. Note that this 1126 * is a policy check only. Currently the method does not attempt to verify 1127 * available space or any other environmental aspects possibly resulting in 1128 * failure to copy. 1129 * 1130 * @return true if the list of files can be copied to destination. 1131 */ 1132 private boolean canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest) { 1133 if (dest == null || !dest.isDirectory() || !dest.isCreateSupported()) { 1134 return false; 1135 } 1136 1137 // Can't copy folders to downloads, because we don't show folders there. 1138 if (root.isDownloads()) { 1139 for (DocumentInfo docs : files) { 1140 if (docs.isDirectory()) { 1141 return false; 1142 } 1143 } 1144 } 1145 1146 return true; 1147 } 1148 1149 public void selectAllFiles() { 1150 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SELECT_ALL); 1151 1152 // Exclude disabled files. 1153 Set<String> enabled = new HashSet<String>(); 1154 List<String> modelIds = mAdapter.getModelIds(); 1155 1156 // Get the current selection. 1157 String[] alreadySelected = mSelectionManager.getSelection().getAll(); 1158 for (String id : alreadySelected) { 1159 enabled.add(id); 1160 } 1161 1162 for (String id : modelIds) { 1163 Cursor cursor = getModel().getItem(id); 1164 if (cursor == null) { 1165 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id); 1166 continue; 1167 } 1168 String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1169 int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 1170 if (mTuner.canSelectType(docMimeType, docFlags)) { 1171 if (enabled.size() >= MAX_DOCS_IN_INTENT) { 1172 Snackbars.makeSnackbar( 1173 getActivity(), 1174 R.string.too_many_in_select_all, 1175 Snackbar.LENGTH_SHORT) 1176 .show(); 1177 break; 1178 } 1179 enabled.add(id); 1180 } 1181 } 1182 1183 // Only select things currently visible in the adapter. 1184 boolean changed = mSelectionManager.setItemsSelected(enabled, true); 1185 if (changed) { 1186 updateDisplayState(); 1187 } 1188 } 1189 1190 /** 1191 * Attempts to restore focus on the directory listing. 1192 */ 1193 public void requestFocus() { 1194 mFocusManager.restoreLastFocus(); 1195 } 1196 1197 private void setupDragAndDropOnDocumentView(View view, Cursor cursor) { 1198 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1199 if (Document.MIME_TYPE_DIR.equals(docMimeType)) { 1200 // Make a directory item a drop target. Drop on non-directories and empty space 1201 // is handled at the list/grid view level. 1202 view.setOnDragListener(mOnDragListener); 1203 } 1204 1205 if (mTuner.dragAndDropEnabled()) { 1206 // Make all items draggable. 1207 view.setOnLongClickListener(onLongClickListener); 1208 } 1209 } 1210 1211 private View.OnDragListener mOnDragListener = new View.OnDragListener() { 1212 @Override 1213 public boolean onDrag(View v, DragEvent event) { 1214 switch (event.getAction()) { 1215 case DragEvent.ACTION_DRAG_STARTED: 1216 // TODO: Check if the event contains droppable data. 1217 return true; 1218 1219 // TODO: Expand drop target directory on hover? 1220 case DragEvent.ACTION_DRAG_ENTERED: 1221 setDropTargetHighlight(v, true); 1222 return true; 1223 case DragEvent.ACTION_DRAG_EXITED: 1224 setDropTargetHighlight(v, false); 1225 return true; 1226 1227 case DragEvent.ACTION_DRAG_LOCATION: 1228 return true; 1229 1230 case DragEvent.ACTION_DRAG_ENDED: 1231 if (event.getResult()) { 1232 // Exit selection mode if the drop was handled. 1233 mSelectionManager.clearSelection(); 1234 } 1235 return true; 1236 1237 case DragEvent.ACTION_DROP: 1238 // After a drop event, always stop highlighting the target. 1239 setDropTargetHighlight(v, false); 1240 1241 ClipData clipData = event.getClipData(); 1242 if (clipData == null) { 1243 Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring."); 1244 return false; 1245 } 1246 1247 // Don't copy from the cwd into the cwd. Note: this currently doesn't work for 1248 // multi-window drag, because localState isn't carried over from one process to 1249 // another. 1250 Object src = event.getLocalState(); 1251 DocumentInfo dst = getDestination(v); 1252 if (Objects.equals(src, dst)) { 1253 if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring."); 1254 return false; 1255 } 1256 1257 // Recognize multi-window drag and drop based on the fact that localState is not 1258 // carried between processes. It will stop working when the localsState behavior 1259 // is changed. The info about window should be passed in the localState then. 1260 // The localState could also be null for copying from Recents in single window 1261 // mode, but Recents doesn't offer this functionality (no directories). 1262 Metrics.logUserAction(getContext(), 1263 src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW 1264 : Metrics.USER_ACTION_DRAG_N_DROP); 1265 1266 copyFromClipData(clipData, dst); 1267 return true; 1268 } 1269 return false; 1270 } 1271 1272 private DocumentInfo getDestination(View v) { 1273 String id = getModelId(v); 1274 if (id != null) { 1275 Cursor dstCursor = mModel.getItem(id); 1276 if (dstCursor == null) { 1277 Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id); 1278 return null; 1279 } 1280 return DocumentInfo.fromDirectoryCursor(dstCursor); 1281 } 1282 1283 if (v == mRecView || v == mEmptyView) { 1284 return getDisplayState().stack.peek(); 1285 } 1286 1287 return null; 1288 } 1289 1290 private void setDropTargetHighlight(View v, boolean highlight) { 1291 // Note: use exact comparison - this code is searching for views which are children of 1292 // the RecyclerView instance in the UI. 1293 if (v.getParent() == mRecView) { 1294 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v); 1295 if (vh instanceof DocumentHolder) { 1296 ((DocumentHolder) vh).setHighlighted(highlight); 1297 } 1298 } 1299 } 1300 }; 1301 1302 /** 1303 * Gets the model ID for a given motion event (using the event position) 1304 */ 1305 private String getModelId(MotionEvent e) { 1306 View view = mRecView.findChildViewUnder(e.getX(), e.getY()); 1307 if (view == null) { 1308 return null; 1309 } 1310 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(view); 1311 if (vh instanceof DocumentHolder) { 1312 return ((DocumentHolder) vh).modelId; 1313 } else { 1314 return null; 1315 } 1316 } 1317 1318 /** 1319 * Gets the model ID for a given RecyclerView item. 1320 * @param view A View that is a document item view, or a child of a document item view. 1321 * @return The Model ID for the given document, or null if the given view is not associated with 1322 * a document item view. 1323 */ 1324 private String getModelId(View view) { 1325 View itemView = mRecView.findContainingItemView(view); 1326 if (itemView != null) { 1327 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView); 1328 if (vh instanceof DocumentHolder) { 1329 return ((DocumentHolder) vh).modelId; 1330 } 1331 } 1332 return null; 1333 } 1334 1335 private List<DocumentInfo> getDraggableDocuments(View currentItemView) { 1336 String modelId = getModelId(currentItemView); 1337 if (modelId == null) { 1338 return Collections.EMPTY_LIST; 1339 } 1340 1341 final List<DocumentInfo> selectedDocs = 1342 mModel.getDocuments(mSelectionManager.getSelection()); 1343 if (!selectedDocs.isEmpty()) { 1344 if (!isSelected(modelId)) { 1345 // There is a selection that does not include the current item, drag nothing. 1346 return Collections.EMPTY_LIST; 1347 } 1348 return selectedDocs; 1349 } 1350 1351 final Cursor cursor = mModel.getItem(modelId); 1352 if (cursor == null) { 1353 Log.w(TAG, "Undraggable document. Can't obtain cursor for modelId " + modelId); 1354 return Collections.EMPTY_LIST; 1355 } 1356 1357 return Lists.newArrayList( 1358 DocumentInfo.fromDirectoryCursor(cursor)); 1359 } 1360 1361 private static class DragShadowBuilder extends View.DragShadowBuilder { 1362 1363 private final Context mContext; 1364 private final IconHelper mIconHelper; 1365 private final LayoutInflater mInflater; 1366 private final View mShadowView; 1367 private final TextView mTitle; 1368 private final ImageView mIcon; 1369 private final int mWidth; 1370 private final int mHeight; 1371 1372 public DragShadowBuilder(Context context, IconHelper iconHelper, List<DocumentInfo> docs) { 1373 mContext = context; 1374 mIconHelper = iconHelper; 1375 mInflater = LayoutInflater.from(context); 1376 1377 mWidth = mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_width); 1378 mHeight= mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_height); 1379 1380 mShadowView = mInflater.inflate(R.layout.drag_shadow_layout, null); 1381 mTitle = (TextView) mShadowView.findViewById(android.R.id.title); 1382 mIcon = (ImageView) mShadowView.findViewById(android.R.id.icon); 1383 1384 mTitle.setText(getTitle(docs)); 1385 mIcon.setImageDrawable(getIcon(docs)); 1386 } 1387 1388 private Drawable getIcon(List<DocumentInfo> docs) { 1389 if (docs.size() == 1) { 1390 final DocumentInfo doc = docs.get(0); 1391 return mIconHelper.getDocumentIcon(mContext, doc.authority, doc.documentId, 1392 doc.mimeType, doc.icon); 1393 } 1394 return mContext.getDrawable(R.drawable.ic_doc_generic); 1395 } 1396 1397 private String getTitle(List<DocumentInfo> docs) { 1398 if (docs.size() == 1) { 1399 final DocumentInfo doc = docs.get(0); 1400 return doc.displayName; 1401 } 1402 return Shared.getQuantityString(mContext, R.plurals.elements_dragged, docs.size()); 1403 } 1404 1405 @Override 1406 public void onProvideShadowMetrics( 1407 Point shadowSize, Point shadowTouchPoint) { 1408 shadowSize.set(mWidth, mHeight); 1409 shadowTouchPoint.set(mWidth, mHeight); 1410 } 1411 1412 @Override 1413 public void onDrawShadow(Canvas canvas) { 1414 Rect r = canvas.getClipBounds(); 1415 // Calling measure is necessary in order for all child views to get correctly laid out. 1416 mShadowView.measure( 1417 View.MeasureSpec.makeMeasureSpec(r.right- r.left, View.MeasureSpec.EXACTLY), 1418 View.MeasureSpec.makeMeasureSpec(r.top- r.bottom, View.MeasureSpec.EXACTLY)); 1419 mShadowView.layout(r.left, r.top, r.right, r.bottom); 1420 mShadowView.draw(canvas); 1421 } 1422 } 1423 /** 1424 * Abstract task providing support for loading documents *off* 1425 * the main thread. And if it isn't obvious, creating a list 1426 * of documents (especially large lists) can be pretty expensive. 1427 */ 1428 private abstract class GetDocumentsTask 1429 extends AsyncTask<Selection, Void, List<DocumentInfo>> { 1430 @Override 1431 protected final List<DocumentInfo> doInBackground(Selection... selected) { 1432 return mModel.getDocuments(selected[0]); 1433 } 1434 1435 @Override 1436 protected final void onPostExecute(List<DocumentInfo> docs) { 1437 onDocumentsReady(docs); 1438 } 1439 1440 abstract void onDocumentsReady(List<DocumentInfo> docs); 1441 } 1442 1443 @Override 1444 public boolean isSelected(String modelId) { 1445 return mSelectionManager.getSelection().contains(modelId); 1446 } 1447 1448 private class ItemEventListener implements DocumentHolder.EventListener { 1449 @Override 1450 public boolean onActivate(DocumentHolder doc) { 1451 // Toggle selection if we're in selection mode, othewise, view item. 1452 if (mSelectionManager.hasSelection()) { 1453 mSelectionManager.toggleSelection(doc.modelId); 1454 } else { 1455 handleViewItem(doc.modelId); 1456 } 1457 return true; 1458 } 1459 1460 @Override 1461 public boolean onSelect(DocumentHolder doc) { 1462 mSelectionManager.toggleSelection(doc.modelId); 1463 mSelectionManager.setSelectionRangeBegin(doc.getAdapterPosition()); 1464 return true; 1465 } 1466 1467 @Override 1468 public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) { 1469 // Only handle key-down events. This is simpler, consistent with most other UIs, and 1470 // enables the handling of repeated key events from holding down a key. 1471 if (event.getAction() != KeyEvent.ACTION_DOWN) { 1472 return false; 1473 } 1474 1475 // Ignore tab key events. Those should be handled by the top-level key handler. 1476 if (keyCode == KeyEvent.KEYCODE_TAB) { 1477 return false; 1478 } 1479 1480 if (mFocusManager.handleKey(doc, keyCode, event)) { 1481 // Handle range selection adjustments. Extending the selection will adjust the 1482 // bounds of the in-progress range selection. Each time an unshifted navigation 1483 // event is received, the range selection is restarted. 1484 if (shouldExtendSelection(doc, event)) { 1485 if (!mSelectionManager.isRangeSelectionActive()) { 1486 // Start a range selection if one isn't active 1487 mSelectionManager.startRangeSelection(doc.getAdapterPosition()); 1488 } 1489 mSelectionManager.snapRangeSelection(mFocusManager.getFocusPosition()); 1490 } else { 1491 mSelectionManager.endRangeSelection(); 1492 } 1493 return true; 1494 } 1495 1496 // Handle enter key events 1497 switch (keyCode) { 1498 case KeyEvent.KEYCODE_ENTER: 1499 if (event.isShiftPressed()) { 1500 return onSelect(doc); 1501 } 1502 // For non-shifted enter keypresses, fall through. 1503 case KeyEvent.KEYCODE_DPAD_CENTER: 1504 case KeyEvent.KEYCODE_BUTTON_A: 1505 return onActivate(doc); 1506 case KeyEvent.KEYCODE_FORWARD_DEL: 1507 // This has to be handled here instead of in a keyboard shortcut, because 1508 // keyboard shortcuts all have to be modified with the 'Ctrl' key. 1509 if (mSelectionManager.hasSelection()) { 1510 Selection selection = mSelectionManager.getSelection(new Selection()); 1511 deleteDocuments(selection); 1512 } 1513 // Always handle the key, even if there was nothing to delete. This is a 1514 // precaution to prevent other handlers from potentially picking up the event 1515 // and triggering extra behaviours. 1516 return true; 1517 } 1518 1519 return false; 1520 } 1521 1522 private boolean shouldExtendSelection(DocumentHolder doc, KeyEvent event) { 1523 if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) { 1524 return false; 1525 } 1526 1527 // TODO: Combine this method with onBeforeItemStateChange, as both of them are almost 1528 // the same, and responsible for the same thing (whether to select or not). 1529 final Cursor cursor = mModel.getItem(doc.modelId); 1530 if (cursor == null) { 1531 Log.w(TAG, "Couldn't obtain cursor for modelId: " + doc.modelId); 1532 return false; 1533 } 1534 1535 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1536 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 1537 return mTuner.canSelectType(docMimeType, docFlags); 1538 } 1539 } 1540 1541 private final class ModelUpdateListener implements Model.UpdateListener { 1542 @Override 1543 public void onModelUpdate(Model model) { 1544 if (model.info != null || model.error != null) { 1545 mMessageBar.setInfo(model.info); 1546 mMessageBar.setError(model.error); 1547 mMessageBar.show(); 1548 } 1549 1550 mProgressBar.setVisibility(model.isLoading() ? View.VISIBLE : View.GONE); 1551 1552 if (model.isEmpty()) { 1553 if (mSearchMode) { 1554 showNoResults(getDisplayState().stack.root); 1555 } else { 1556 showEmptyDirectory(); 1557 } 1558 } else { 1559 showDirectory(); 1560 mAdapter.notifyDataSetChanged(); 1561 } 1562 1563 if (!model.isLoading()) { 1564 ((BaseActivity) getActivity()).notifyDirectoryLoaded( 1565 model.doc != null ? model.doc.derivedUri : null); 1566 } 1567 } 1568 1569 @Override 1570 public void onModelUpdateFailed(Exception e) { 1571 showQueryError(); 1572 } 1573 } 1574 1575 private DragStartHelper.OnDragStartListener mOnDragStartListener = 1576 new DragStartHelper.OnDragStartListener() { 1577 @Override 1578 public boolean onDragStart(View v, DragStartHelper helper) { 1579 if (isSelected(getModelId(v))) { 1580 List<DocumentInfo> docs = getDraggableDocuments(v); 1581 if (docs.isEmpty()) { 1582 return false; 1583 } 1584 v.startDragAndDrop( 1585 mClipper.getClipDataForDocuments(docs), 1586 new DragShadowBuilder(getActivity(), mIconHelper, docs), 1587 getDisplayState().stack.peek(), 1588 View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ | 1589 View.DRAG_FLAG_GLOBAL_URI_WRITE 1590 ); 1591 return true; 1592 } 1593 1594 return false; 1595 } 1596 }; 1597 1598 private DragStartHelper mDragHelper = new DragStartHelper(null, mOnDragStartListener); 1599 1600 private View.OnLongClickListener onLongClickListener = new View.OnLongClickListener() { 1601 @Override 1602 public boolean onLongClick(View v) { 1603 return mDragHelper.onLongClick(v); 1604 } 1605 }; 1606 1607 // Previously we listened to events with one class, only to bounce them forward 1608 // to GestureDetector. We're still doing that here, but with a single class 1609 // that reduces overall complexity in our glue code. 1610 private static final class ListeningGestureDetector extends GestureDetector 1611 implements OnItemTouchListener { 1612 1613 private int mLastTool = -1; 1614 private DragStartHelper mDragHelper; 1615 1616 public ListeningGestureDetector( 1617 Context context, DragStartHelper dragHelper, GestureListener listener) { 1618 super(context, listener); 1619 mDragHelper = dragHelper; 1620 setOnDoubleTapListener(listener); 1621 } 1622 1623 boolean mouseSpawnedLastEvent() { 1624 return Events.isMouseType(mLastTool); 1625 } 1626 1627 boolean touchSpawnedLastEvent() { 1628 return Events.isTouchType(mLastTool); 1629 } 1630 1631 @Override 1632 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { 1633 mLastTool = e.getToolType(0); 1634 1635 // Detect drag events. When a drag is detected, intercept the rest of the gesture. 1636 View itemView = rv.findChildViewUnder(e.getX(), e.getY()); 1637 if (itemView != null && mDragHelper.onTouch(itemView, e)) { 1638 return true; 1639 } 1640 // Forward unhandled events to the GestureDetector. 1641 onTouchEvent(e); 1642 1643 return false; 1644 } 1645 1646 @Override 1647 public void onTouchEvent(RecyclerView rv, MotionEvent e) { 1648 View itemView = rv.findChildViewUnder(e.getX(), e.getY()); 1649 mDragHelper.onTouch(itemView, e); 1650 // Note: even though this event is being handled as part of a drag gesture, continue 1651 // forwarding to the GestureDetector. The detector needs to see the entire cluster of 1652 // events in order to properly interpret gestures. 1653 onTouchEvent(e); 1654 } 1655 1656 @Override 1657 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} 1658 } 1659 1660 /** 1661 * The gesture listener for items in the list/grid view. Interprets gestures and sends the 1662 * events to the target DocumentHolder, whence they are routed to the appropriate listener. 1663 */ 1664 private class GestureListener extends GestureDetector.SimpleOnGestureListener { 1665 @Override 1666 public boolean onSingleTapUp(MotionEvent e) { 1667 // Single tap logic: 1668 // If the selection manager is active, it gets first whack at handling tap 1669 // events. Otherwise, tap events are routed to the target DocumentHolder. 1670 boolean handled = mSelectionManager.onSingleTapUp( 1671 new MotionInputEvent(e, mRecView)); 1672 1673 if (handled) { 1674 return handled; 1675 } 1676 1677 // Give the DocumentHolder a crack at the event. 1678 DocumentHolder holder = getTarget(e); 1679 if (holder != null) { 1680 handled = holder.onSingleTapUp(e); 1681 } 1682 1683 return handled; 1684 } 1685 1686 @Override 1687 public void onLongPress(MotionEvent e) { 1688 // Long-press events get routed directly to the selection manager. They can be 1689 // changed to route through the DocumentHolder if necessary. 1690 mSelectionManager.onLongPress(new MotionInputEvent(e, mRecView)); 1691 } 1692 1693 @Override 1694 public boolean onDoubleTap(MotionEvent e) { 1695 // Double-tap events are handled directly by the DirectoryFragment. They can be changed 1696 // to route through the DocumentHolder if necessary. 1697 return DirectoryFragment.this.onDoubleTap(e); 1698 } 1699 1700 private @Nullable DocumentHolder getTarget(MotionEvent e) { 1701 View childView = mRecView.findChildViewUnder(e.getX(), e.getY()); 1702 if (childView != null) { 1703 return (DocumentHolder) mRecView.getChildViewHolder(childView); 1704 } else { 1705 return null; 1706 } 1707 } 1708 } 1709 1710 public static void showDirectory( 1711 FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) { 1712 create(fm, TYPE_NORMAL, root, doc, null, anim); 1713 } 1714 1715 public static void showRecentsOpen(FragmentManager fm, int anim) { 1716 create(fm, TYPE_RECENT_OPEN, null, null, null, anim); 1717 } 1718 1719 public static void reloadSearch(FragmentManager fm, RootInfo root, DocumentInfo doc, 1720 String query) { 1721 DirectoryFragment df = get(fm); 1722 1723 df.mQuery = query; 1724 df.mRoot = root; 1725 df.mDocument = doc; 1726 df.mSearchMode = query != null; 1727 df.getLoaderManager().restartLoader(LOADER_ID, null, df); 1728 } 1729 1730 public static void reload(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, 1731 String query) { 1732 DirectoryFragment df = get(fm); 1733 df.mType = type; 1734 df.mQuery = query; 1735 df.mRoot = root; 1736 df.mDocument = doc; 1737 df.mSearchMode = query != null; 1738 df.getLoaderManager().restartLoader(LOADER_ID, null, df); 1739 } 1740 1741 public static void create(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, 1742 String query, int anim) { 1743 final Bundle args = new Bundle(); 1744 args.putInt(Shared.EXTRA_TYPE, type); 1745 args.putParcelable(Shared.EXTRA_ROOT, root); 1746 args.putParcelable(Shared.EXTRA_DOC, doc); 1747 args.putString(Shared.EXTRA_QUERY, query); 1748 args.putParcelable(Shared.EXTRA_SELECTION, new Selection()); 1749 1750 final FragmentTransaction ft = fm.beginTransaction(); 1751 AnimationView.setupAnimations(ft, anim, args); 1752 1753 final DirectoryFragment fragment = new DirectoryFragment(); 1754 fragment.setArguments(args); 1755 1756 ft.replace(getFragmentId(), fragment); 1757 ft.commitAllowingStateLoss(); 1758 } 1759 1760 private static String buildStateKey(RootInfo root, DocumentInfo doc) { 1761 final StringBuilder builder = new StringBuilder(); 1762 builder.append(root != null ? root.authority : "null").append(';'); 1763 builder.append(root != null ? root.rootId : "null").append(';'); 1764 builder.append(doc != null ? doc.documentId : "null"); 1765 return builder.toString(); 1766 } 1767 1768 public static @Nullable DirectoryFragment get(FragmentManager fm) { 1769 // TODO: deal with multiple directories shown at once 1770 Fragment fragment = fm.findFragmentById(getFragmentId()); 1771 return fragment instanceof DirectoryFragment 1772 ? (DirectoryFragment) fragment 1773 : null; 1774 } 1775 1776 private static int getFragmentId() { 1777 return R.id.container_directory; 1778 } 1779 1780 @Override 1781 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { 1782 Context context = getActivity(); 1783 State state = getDisplayState(); 1784 1785 Uri contentsUri; 1786 switch (mType) { 1787 case TYPE_NORMAL: 1788 contentsUri = mSearchMode ? DocumentsContract.buildSearchDocumentsUri( 1789 mRoot.authority, mRoot.rootId, mQuery) 1790 : DocumentsContract.buildChildDocumentsUri( 1791 mDocument.authority, mDocument.documentId); 1792 if (mTuner.managedModeEnabled()) { 1793 contentsUri = DocumentsContract.setManageMode(contentsUri); 1794 } 1795 return new DirectoryLoader( 1796 context, mType, mRoot, mDocument, contentsUri, state.userSortOrder, 1797 mSearchMode); 1798 case TYPE_RECENT_OPEN: 1799 final RootsCache roots = DocumentsApplication.getRootsCache(context); 1800 return new RecentsLoader(context, roots, state); 1801 1802 default: 1803 throw new IllegalStateException("Unknown type " + mType); 1804 } 1805 } 1806 1807 @Override 1808 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { 1809 if (!isAdded()) return; 1810 1811 if (mSearchMode) { 1812 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SEARCH); 1813 } 1814 1815 State state = getDisplayState(); 1816 1817 mAdapter.notifyDataSetChanged(); 1818 mModel.update(result); 1819 1820 state.derivedSortOrder = result.sortOrder; 1821 1822 updateLayout(state.derivedMode); 1823 1824 if (mSelection != null) { 1825 mSelectionManager.setItemsSelected(mSelection.toList(), true); 1826 mSelection.clear(); 1827 } 1828 1829 // Restore any previous instance state 1830 final SparseArray<Parcelable> container = state.dirState.remove(mStateKey); 1831 if (container != null && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, false)) { 1832 getView().restoreHierarchyState(container); 1833 } else if (mLastSortOrder != state.derivedSortOrder) { 1834 // The derived sort order takes the user sort order into account, but applies 1835 // directory-specific defaults when the user doesn't explicitly set the sort 1836 // order. Scroll to the top if the sort order actually changed. 1837 mRecView.smoothScrollToPosition(0); 1838 } 1839 1840 mLastSortOrder = state.derivedSortOrder; 1841 1842 mTuner.onModelLoaded(mModel, mType, mSearchMode); 1843 1844 } 1845 1846 @Override 1847 public void onLoaderReset(Loader<DirectoryResult> loader) { 1848 mModel.update(null); 1849 } 1850 } 1851