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