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