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