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