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 .withGestureTooltypes(MotionEvent.TOOL_TYPE_FINGER, 362 MotionEvent.TOOL_TYPE_STYLUS) 363 .build(); 364 mInjector.updateSharedSelectionTracker(localTracker); 365 } 366 367 mSelectionMgr.addObserver(mSelectionMetadata); 368 369 // Construction of the input handlers is non trivial, so to keep logic clear, 370 // and code flexible, and DirectoryFragment small, the construction has been 371 // moved off into a separate class. 372 InputHandlers handlers = new InputHandlers( 373 mActions, 374 mSelectionMgr, 375 selectionPredicate, 376 mFocusManager, 377 mRecView); 378 379 // This little guy gets added to each Holder, so that we can be notified of key events 380 // on RecyclerView items. 381 mKeyListener = handlers.createKeyHandler(); 382 383 if (DEBUG) { 384 new ScaleHelper(this.getContext(), mInjector.features, this::scaleLayout) 385 .attach(mRecView); 386 } 387 388 new RefreshHelper(mRefreshLayout::setEnabled) 389 .attach(mRecView); 390 391 mActionModeController = mInjector.getActionModeController( 392 mSelectionMetadata, 393 this::handleMenuItemClick); 394 395 mSelectionMgr.addObserver(mActionModeController); 396 397 final ActivityManager am = (ActivityManager) mActivity.getSystemService( 398 Context.ACTIVITY_SERVICE); 399 boolean svelte = am.isLowRamDevice() && (mState.stack.isRecents()); 400 mIconHelper.setThumbnailsEnabled(!svelte); 401 402 // If mDocument is null, we sort it by last modified by default because it's in Recents. 403 final boolean prefersLastModified = 404 (mLocalState.mDocument == null) 405 || mLocalState.mDocument.prefersSortByLastModified(); 406 // Call this before adding the listener to avoid restarting the loader one more time 407 mState.sortModel.setDefaultDimension( 408 prefersLastModified 409 ? SortModel.SORT_DIMENSION_ID_DATE 410 : SortModel.SORT_DIMENSION_ID_TITLE); 411 412 // Kick off loader at least once 413 mActions.loadDocumentsForCurrentStack(); 414 } 415 416 @Override onStart()417 public void onStart() { 418 super.onStart(); 419 420 // Add listener to update contents on sort model change 421 mState.sortModel.addListener(mSortListener); 422 } 423 424 @Override onStop()425 public void onStop() { 426 super.onStop(); 427 428 mState.sortModel.removeListener(mSortListener); 429 430 // Remember last scroll location 431 final SparseArray<Parcelable> container = new SparseArray<>(); 432 getView().saveHierarchyState(container); 433 mState.dirConfigs.put(mLocalState.getConfigKey(), container); 434 } 435 436 @Override onSaveInstanceState(Bundle outState)437 public void onSaveInstanceState(Bundle outState) { 438 super.onSaveInstanceState(outState); 439 440 mLocalState.save(outState); 441 mSelectionMgr.onSaveInstanceState(outState); 442 } 443 444 @Override onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo)445 public void onCreateContextMenu(ContextMenu menu, 446 View v, 447 ContextMenu.ContextMenuInfo menuInfo) { 448 super.onCreateContextMenu(menu, v, menuInfo); 449 final MenuInflater inflater = getActivity().getMenuInflater(); 450 451 final String modelId = getModelId(v); 452 if (modelId == null) { 453 // TODO: inject DirectoryDetails into MenuManager constructor 454 // Since both classes are supplied by Activity and created 455 // at the same time. 456 mInjector.menuManager.inflateContextMenuForContainer(menu, inflater); 457 } else { 458 mInjector.menuManager.inflateContextMenuForDocs( 459 menu, inflater, mSelectionMetadata); 460 } 461 } 462 463 @Override onContextItemSelected(MenuItem item)464 public boolean onContextItemSelected(MenuItem item) { 465 return handleMenuItemClick(item); 466 } 467 onCopyDestinationPicked(int resultCode, Intent data)468 private void onCopyDestinationPicked(int resultCode, Intent data) { 469 470 FileOperation operation = mLocalState.claimPendingOperation(); 471 472 if (resultCode == FragmentActivity.RESULT_CANCELED || data == null) { 473 // User pressed the back button or otherwise cancelled the destination pick. Don't 474 // proceed with the copy. 475 operation.dispose(); 476 return; 477 } 478 479 operation.setDestination(data.getParcelableExtra(Shared.EXTRA_STACK)); 480 final String jobId = FileOperations.createJobId(); 481 mInjector.dialogs.showProgressDialog(jobId, operation); 482 FileOperations.start( 483 mActivity, 484 operation, 485 mInjector.dialogs::showFileOperationStatus, 486 jobId); 487 } 488 489 // TODO: Move to UserInputHander. onContextMenuClick(MotionEvent e)490 protected boolean onContextMenuClick(MotionEvent e) { 491 492 if (mDetailsLookup.overItemWithSelectionKey(e)) { 493 View childView = mRecView.findChildViewUnder(e.getX(), e.getY()); 494 ViewHolder holder = mRecView.getChildViewHolder(childView); 495 496 View view = holder.itemView; 497 float x = e.getX() - view.getLeft(); 498 float y = e.getY() - view.getTop(); 499 mInjector.menuManager.showContextMenu(this, view, x, y); 500 return true; 501 } 502 503 mInjector.menuManager.showContextMenu(this, mRecView, e.getX(), e.getY()); 504 return true; 505 } 506 onItemActivated(ItemDetails<String> item, MotionEvent e)507 private boolean onItemActivated(ItemDetails<String> item, MotionEvent e) { 508 if (((DocumentItemDetails) item).inPreviewIconHotspot(e)) { 509 return mActions.previewItem(item); 510 } 511 512 return mActions.openItem( 513 item, 514 ActionHandler.VIEW_TYPE_PREVIEW, 515 ActionHandler.VIEW_TYPE_REGULAR); 516 } 517 onViewModeChanged()518 public void onViewModeChanged() { 519 // Mode change is just visual change; no need to kick loader. 520 onDisplayStateChanged(); 521 } 522 onDisplayStateChanged()523 private void onDisplayStateChanged() { 524 updateLayout(mState.derivedMode); 525 mRecView.setAdapter(mAdapter); 526 } 527 528 /** 529 * Updates the layout after the view mode switches. 530 * @param mode The new view mode. 531 */ updateLayout(@iewMode int mode)532 private void updateLayout(@ViewMode int mode) { 533 mMode = mode; 534 mColumnCount = calculateColumnCount(mode); 535 if (mLayout != null) { 536 mLayout.setSpanCount(mColumnCount); 537 } 538 539 int pad = getDirectoryPadding(mode); 540 mAppBarHeight = getAppBarLayoutHeight(); 541 mRecView.setPadding(pad, mAppBarHeight, pad, getSaveLayoutHeight()); 542 mRecView.requestLayout(); 543 mIconHelper.setViewMode(mode); 544 545 int range = getResources().getDimensionPixelOffset(R.dimen.refresh_icon_range); 546 mRefreshLayout.setProgressViewOffset(true, mAppBarHeight, mAppBarHeight + range); 547 } 548 getAppBarLayoutHeight()549 private int getAppBarLayoutHeight() { 550 View appBarLayout = getActivity().findViewById(R.id.app_bar); 551 View collapsingBar = getActivity().findViewById(R.id.collapsing_toolbar); 552 return collapsingBar == null ? 0 : appBarLayout.getHeight(); 553 } 554 getSaveLayoutHeight()555 private int getSaveLayoutHeight() { 556 View containerSave = getActivity().findViewById(R.id.container_save); 557 return containerSave == null ? 0 : containerSave.getHeight(); 558 } 559 560 /** 561 * Updates the layout after the view mode switches. 562 * @param mode The new view mode. 563 */ scaleLayout(float scale)564 private void scaleLayout(float scale) { 565 assert DEBUG; 566 567 if (VERBOSE) Log.v( 568 TAG, "Handling scale event: " + scale + ", existing scale: " + mLiveScale); 569 570 if (mMode == MODE_GRID) { 571 float minScale = getFraction(R.fraction.grid_scale_min); 572 float maxScale = getFraction(R.fraction.grid_scale_max); 573 float nextScale = mLiveScale * scale; 574 575 if (VERBOSE) Log.v(TAG, 576 "Next scale " + nextScale + ", Min/max scale " + minScale + "/" + maxScale); 577 578 if (nextScale > minScale && nextScale < maxScale) { 579 if (DEBUG) { 580 Log.d(TAG, "Updating grid scale: " + scale); 581 } 582 mLiveScale = nextScale; 583 updateLayout(mMode); 584 } 585 586 } else { 587 if (DEBUG) { 588 Log.d(TAG, "List mode, ignoring scale: " + scale); 589 } 590 mLiveScale = 1.0f; 591 } 592 } 593 calculateColumnCount(@iewMode int mode)594 private int calculateColumnCount(@ViewMode int mode) { 595 if (mode == MODE_LIST) { 596 // List mode is a "grid" with 1 column. 597 return 1; 598 } 599 600 int cellWidth = getScaledSize(R.dimen.grid_width); 601 int cellMargin = 2 * getScaledSize(R.dimen.grid_item_margin); 602 int viewPadding = 603 (int) ((mRecView.getPaddingLeft() + mRecView.getPaddingRight()) * mLiveScale); 604 605 // RecyclerView sometimes gets a width of 0 (see b/27150284). 606 // Clamp so that we always lay out the grid with at least 2 columns by default. 607 // If on photo picking state, the UI should show 3 images a row or 2 folders a row, 608 // so use 6 columns by default and set folder size to 3 and document size is to 2. 609 mColumnUnit = mState.isPhotoPicking() ? 3 : 1; 610 int columnCount = mColumnUnit * Math.max(2, 611 (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin)); 612 613 // Finally with our grid count logic firmly in place, we apply any live scaling 614 // captured by the scale gesture detector. 615 return Math.max(1, Math.round(columnCount / mLiveScale)); 616 } 617 618 619 /** 620 * Moderately abuse the "fraction" resource type for our purposes. 621 */ getFraction(@ractionRes int id)622 private float getFraction(@FractionRes int id) { 623 return getResources().getFraction(id, 1, 0); 624 } 625 getScaledSize(@imenRes int id)626 private int getScaledSize(@DimenRes int id) { 627 return (int) (getResources().getDimensionPixelSize(id) * mLiveScale); 628 } 629 getDirectoryPadding(@iewMode int mode)630 private int getDirectoryPadding(@ViewMode int mode) { 631 switch (mode) { 632 case MODE_GRID: 633 return getResources().getDimensionPixelSize(R.dimen.grid_container_padding); 634 case MODE_LIST: 635 return getResources().getDimensionPixelSize(R.dimen.list_container_padding); 636 default: 637 throw new IllegalArgumentException("Unsupported layout mode: " + mode); 638 } 639 } 640 handleMenuItemClick(MenuItem item)641 private boolean handleMenuItemClick(MenuItem item) { 642 if (mInjector.pickResult != null) { 643 mInjector.pickResult.increaseActionCount(); 644 } 645 MutableSelection<String> selection = new MutableSelection<>(); 646 mSelectionMgr.copySelection(selection); 647 648 switch (item.getItemId()) { 649 case R.id.action_menu_select: 650 case R.id.dir_menu_open: 651 openDocuments(selection); 652 mActionModeController.finishActionMode(); 653 return true; 654 655 case R.id.action_menu_open_with: 656 case R.id.dir_menu_open_with: 657 showChooserForDoc(selection); 658 return true; 659 660 case R.id.dir_menu_open_in_new_window: 661 mActions.openSelectedInNewWindow(); 662 return true; 663 664 case R.id.action_menu_share: 665 case R.id.dir_menu_share: 666 mActions.shareSelectedDocuments(); 667 return true; 668 669 case R.id.action_menu_delete: 670 case R.id.dir_menu_delete: 671 // deleteDocuments will end action mode if the documents are deleted. 672 // It won't end action mode if user cancels the delete. 673 mActions.deleteSelectedDocuments(); 674 return true; 675 676 case R.id.action_menu_copy_to: 677 transferDocuments(selection, null, FileOperationService.OPERATION_COPY); 678 // TODO: Only finish selection mode if copy-to is not canceled. 679 // Need to plum down into handling the way we do with deleteDocuments. 680 mActionModeController.finishActionMode(); 681 return true; 682 683 case R.id.action_menu_compress: 684 transferDocuments(selection, mState.stack, 685 FileOperationService.OPERATION_COMPRESS); 686 // TODO: Only finish selection mode if compress is not canceled. 687 // Need to plum down into handling the way we do with deleteDocuments. 688 mActionModeController.finishActionMode(); 689 return true; 690 691 // TODO: Implement extract (to the current directory). 692 case R.id.action_menu_extract_to: 693 transferDocuments(selection, null, FileOperationService.OPERATION_EXTRACT); 694 // TODO: Only finish selection mode if compress-to is not canceled. 695 // Need to plum down into handling the way we do with deleteDocuments. 696 mActionModeController.finishActionMode(); 697 return true; 698 699 case R.id.action_menu_move_to: 700 if (mModel.hasDocuments(selection, DocumentFilters.NOT_MOVABLE)) { 701 mInjector.dialogs.showOperationUnsupported(); 702 return true; 703 } 704 // Exit selection mode first, so we avoid deselecting deleted documents. 705 mActionModeController.finishActionMode(); 706 transferDocuments(selection, null, FileOperationService.OPERATION_MOVE); 707 return true; 708 709 case R.id.action_menu_inspect: 710 case R.id.dir_menu_inspect: 711 mActionModeController.finishActionMode(); 712 assert selection.size() <= 1; 713 DocumentInfo doc = selection.isEmpty() 714 ? mActivity.getCurrentDirectory() 715 : mModel.getDocuments(selection).get(0); 716 717 mActions.showInspector(doc); 718 return true; 719 720 case R.id.dir_menu_cut_to_clipboard: 721 mActions.cutToClipboard(); 722 return true; 723 724 case R.id.dir_menu_copy_to_clipboard: 725 mActions.copyToClipboard(); 726 return true; 727 728 case R.id.dir_menu_paste_from_clipboard: 729 pasteFromClipboard(); 730 return true; 731 732 case R.id.dir_menu_paste_into_folder: 733 pasteIntoFolder(); 734 return true; 735 736 case R.id.action_menu_select_all: 737 case R.id.dir_menu_select_all: 738 mActions.selectAllFiles(); 739 return true; 740 741 case R.id.action_menu_rename: 742 case R.id.dir_menu_rename: 743 // Exit selection mode first, so we avoid deselecting deleted 744 // (renamed) documents. 745 mActionModeController.finishActionMode(); 746 renameDocuments(selection); 747 return true; 748 749 case R.id.dir_menu_create_dir: 750 mActions.showCreateDirectoryDialog(); 751 return true; 752 753 case R.id.dir_menu_view_in_owner: 754 mActions.viewInOwner(); 755 return true; 756 757 case R.id.action_menu_sort: 758 mActions.showSortDialog(); 759 return true; 760 761 default: 762 if (DEBUG) { 763 Log.d(TAG, "Unhandled menu item selected: " + item); 764 } 765 return false; 766 } 767 } 768 onAccessibilityClick(View child)769 private boolean onAccessibilityClick(View child) { 770 if (mSelectionMgr.hasSelection()) { 771 selectItem(child); 772 } else { 773 DocumentHolder holder = getDocumentHolder(child); 774 mActions.openItem(holder.getItemDetails(), ActionHandler.VIEW_TYPE_PREVIEW, 775 ActionHandler.VIEW_TYPE_REGULAR); 776 } 777 return true; 778 } 779 onAccessibilityLongClick(View child)780 private boolean onAccessibilityLongClick(View child) { 781 selectItem(child); 782 return true; 783 } 784 selectItem(View child)785 private void selectItem(View child) { 786 final String id = getModelId(child); 787 if (mSelectionMgr.isSelected(id)) { 788 mSelectionMgr.deselect(id); 789 } else { 790 mSelectionMgr.select(id); 791 } 792 } 793 cancelThumbnailTask(View view)794 private void cancelThumbnailTask(View view) { 795 final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb); 796 if (iconThumb != null) { 797 mIconHelper.stopLoading(iconThumb); 798 } 799 } 800 801 // Support for opening multiple documents is currently exclusive to DocumentsActivity. openDocuments(final Selection selected)802 private void openDocuments(final Selection selected) { 803 Metrics.logUserAction(MetricConsts.USER_ACTION_OPEN); 804 805 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 806 List<DocumentInfo> docs = mModel.getDocuments(selected); 807 if (docs.size() > 1) { 808 mActivity.onDocumentsPicked(docs); 809 } else { 810 mActivity.onDocumentPicked(docs.get(0)); 811 } 812 } 813 showChooserForDoc(final Selection<String> selected)814 private void showChooserForDoc(final Selection<String> selected) { 815 Metrics.logUserAction(MetricConsts.USER_ACTION_OPEN); 816 817 assert selected.size() == 1; 818 DocumentInfo doc = 819 DocumentInfo.fromDirectoryCursor(mModel.getItem(selected.iterator().next())); 820 mActions.showChooserForDoc(doc); 821 } 822 transferDocuments( final Selection<String> selected, @Nullable DocumentStack destination, final @OpType int mode)823 private void transferDocuments( 824 final Selection<String> selected, @Nullable DocumentStack destination, 825 final @OpType int mode) { 826 switch (mode) { 827 case FileOperationService.OPERATION_COPY: 828 Metrics.logUserAction(MetricConsts.USER_ACTION_COPY_TO); 829 break; 830 case FileOperationService.OPERATION_COMPRESS: 831 Metrics.logUserAction(MetricConsts.USER_ACTION_COMPRESS); 832 break; 833 case FileOperationService.OPERATION_EXTRACT: 834 Metrics.logUserAction(MetricConsts.USER_ACTION_EXTRACT_TO); 835 break; 836 case FileOperationService.OPERATION_MOVE: 837 Metrics.logUserAction(MetricConsts.USER_ACTION_MOVE_TO); 838 break; 839 } 840 841 UrisSupplier srcs; 842 try { 843 ClipStore clipStorage = DocumentsApplication.getClipStore(getContext()); 844 srcs = UrisSupplier.create(selected, mModel::getItemUri, clipStorage); 845 } catch (IOException e) { 846 throw new RuntimeException("Failed to create uri supplier.", e); 847 } 848 849 final DocumentInfo parent = mActivity.getCurrentDirectory(); 850 final FileOperation operation = new FileOperation.Builder() 851 .withOpType(mode) 852 .withSrcParent(parent == null ? null : parent.derivedUri) 853 .withSrcs(srcs) 854 .build(); 855 856 if (destination != null) { 857 operation.setDestination(destination); 858 final String jobId = FileOperations.createJobId(); 859 mInjector.dialogs.showProgressDialog(jobId, operation); 860 FileOperations.start( 861 mActivity, 862 operation, 863 mInjector.dialogs::showFileOperationStatus, 864 jobId); 865 return; 866 } 867 868 // Pop up a dialog to pick a destination. This is inadequate but works for now. 869 // TODO: Implement a picker that is to spec. 870 mLocalState.mPendingOperation = operation; 871 final Intent intent = new Intent( 872 Shared.ACTION_PICK_COPY_DESTINATION, 873 Uri.EMPTY, 874 getActivity(), 875 PickActivity.class); 876 877 // Set an appropriate title on the drawer when it is shown in the picker. 878 // Coupled with the fact that we auto-open the drawer for copy/move operations 879 // it should basically be the thing people see first. 880 int drawerTitleId; 881 switch (mode) { 882 case FileOperationService.OPERATION_COPY: 883 drawerTitleId = R.string.menu_copy; 884 break; 885 case FileOperationService.OPERATION_COMPRESS: 886 drawerTitleId = R.string.menu_compress; 887 break; 888 case FileOperationService.OPERATION_EXTRACT: 889 drawerTitleId = R.string.menu_extract; 890 break; 891 case FileOperationService.OPERATION_MOVE: 892 drawerTitleId = R.string.menu_move; 893 break; 894 default: 895 throw new UnsupportedOperationException("Unknown mode: " + mode); 896 } 897 898 intent.putExtra(DocumentsContract.EXTRA_PROMPT, getResources().getString(drawerTitleId)); 899 900 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 901 List<DocumentInfo> docs = mModel.getDocuments(selected); 902 903 // Determine if there is a directory in the set of documents 904 // to be copied? Why? Directory creation isn't supported by some roots 905 // (like Downloads). This informs DocumentsActivity (the "picker") 906 // to restrict available roots to just those with support. 907 intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs)); 908 intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mode); 909 910 // This just identifies the type of request...we'll check it 911 // when we reveive a response. 912 startActivityForResult(intent, REQUEST_COPY_DESTINATION); 913 } 914 915 @Override onActivityResult(@equestCode int requestCode, int resultCode, Intent data)916 public void onActivityResult(@RequestCode int requestCode, int resultCode, Intent data) { 917 switch (requestCode) { 918 case REQUEST_COPY_DESTINATION: 919 onCopyDestinationPicked(resultCode, data); 920 break; 921 default: 922 throw new UnsupportedOperationException("Unknown request code: " + requestCode); 923 } 924 } 925 hasDirectory(List<DocumentInfo> docs)926 private static boolean hasDirectory(List<DocumentInfo> docs) { 927 for (DocumentInfo info : docs) { 928 if (Document.MIME_TYPE_DIR.equals(info.mimeType)) { 929 return true; 930 } 931 } 932 return false; 933 } 934 renameDocuments(Selection selected)935 private void renameDocuments(Selection selected) { 936 Metrics.logUserAction(MetricConsts.USER_ACTION_RENAME); 937 938 // Batch renaming not supported 939 // Rename option is only available in menu when 1 document selected 940 assert selected.size() == 1; 941 942 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 943 List<DocumentInfo> docs = mModel.getDocuments(selected); 944 RenameDocumentFragment.show(getChildFragmentManager(), docs.get(0)); 945 } 946 getModel()947 Model getModel(){ 948 return mModel; 949 } 950 951 /** 952 * Paste selection files from the primary clip into the current window. 953 */ pasteFromClipboard()954 public void pasteFromClipboard() { 955 Metrics.logUserAction(MetricConsts.USER_ACTION_PASTE_CLIPBOARD); 956 // Since we are pasting into the current window, we already have the destination in the 957 // stack. No need for a destination DocumentInfo. 958 mClipper.copyFromClipboard( 959 mState.stack, 960 mInjector.dialogs::showFileOperationStatus); 961 getActivity().invalidateOptionsMenu(); 962 } 963 pasteIntoFolder()964 public void pasteIntoFolder() { 965 assert (mSelectionMgr.getSelection().size() == 1); 966 967 String modelId = mSelectionMgr.getSelection().iterator().next(); 968 Cursor dstCursor = mModel.getItem(modelId); 969 if (dstCursor == null) { 970 Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + modelId); 971 return; 972 } 973 DocumentInfo destination = DocumentInfo.fromDirectoryCursor(dstCursor); 974 mClipper.copyFromClipboard( 975 destination, 976 mState.stack, 977 mInjector.dialogs::showFileOperationStatus); 978 getActivity().invalidateOptionsMenu(); 979 } 980 setupDragAndDropOnDocumentView(View view, Cursor cursor)981 private void setupDragAndDropOnDocumentView(View view, Cursor cursor) { 982 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 983 if (Document.MIME_TYPE_DIR.equals(docMimeType)) { 984 // Make a directory item a drop target. Drop on non-directories and empty space 985 // is handled at the list/grid view level. 986 view.setOnDragListener(mDragHoverListener); 987 } 988 } 989 getDestination(View v)990 private DocumentInfo getDestination(View v) { 991 String id = getModelId(v); 992 if (id != null) { 993 Cursor dstCursor = mModel.getItem(id); 994 if (dstCursor == null) { 995 Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id); 996 return null; 997 } 998 return DocumentInfo.fromDirectoryCursor(dstCursor); 999 } 1000 1001 if (v == mRecView) { 1002 return mActivity.getCurrentDirectory(); 1003 } 1004 1005 return null; 1006 } 1007 1008 /** 1009 * Gets the model ID for a given RecyclerView item. 1010 * @param view A View that is a document item view, or a child of a document item view. 1011 * @return The Model ID for the given document, or null if the given view is not associated with 1012 * a document item view. 1013 */ getModelId(View view)1014 private @Nullable String getModelId(View view) { 1015 View itemView = mRecView.findContainingItemView(view); 1016 if (itemView != null) { 1017 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView); 1018 if (vh instanceof DocumentHolder) { 1019 return ((DocumentHolder) vh).getModelId(); 1020 } 1021 } 1022 return null; 1023 } 1024 getDocumentHolder(View v)1025 private @Nullable DocumentHolder getDocumentHolder(View v) { 1026 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v); 1027 if (vh instanceof DocumentHolder) { 1028 return (DocumentHolder) vh; 1029 } 1030 return null; 1031 } 1032 setPreDrawListener(boolean enable)1033 private void setPreDrawListener(boolean enable) { 1034 if (mActivity == null) { 1035 return; 1036 } 1037 1038 final View bar = mActivity.findViewById(R.id.collapsing_toolbar); 1039 if (bar != null) { 1040 if (enable) { 1041 bar.getViewTreeObserver().addOnPreDrawListener(mToolbarPreDrawListener); 1042 } else { 1043 bar.getViewTreeObserver().removeOnPreDrawListener(mToolbarPreDrawListener); 1044 } 1045 } 1046 } 1047 showDirectory( FragmentManager fm, RootInfo root, DocumentInfo doc, int anim)1048 public static void showDirectory( 1049 FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) { 1050 if (DEBUG) { 1051 Log.d(TAG, "Showing directory: " + DocumentInfo.debugString(doc)); 1052 } 1053 create(fm, root, doc, anim); 1054 } 1055 showRecentsOpen(FragmentManager fm, int anim)1056 public static void showRecentsOpen(FragmentManager fm, int anim) { 1057 create(fm, null, null, anim); 1058 } 1059 create( FragmentManager fm, RootInfo root, @Nullable DocumentInfo doc, @AnimationType int anim)1060 public static void create( 1061 FragmentManager fm, 1062 RootInfo root, 1063 @Nullable DocumentInfo doc, 1064 @AnimationType int anim) { 1065 1066 if (DEBUG) { 1067 if (doc == null) { 1068 Log.d(TAG, "Creating new fragment null directory"); 1069 } else { 1070 Log.d(TAG, "Creating new fragment for directory: " + DocumentInfo.debugString(doc)); 1071 } 1072 } 1073 1074 final Bundle args = new Bundle(); 1075 args.putParcelable(Shared.EXTRA_ROOT, root); 1076 args.putParcelable(Shared.EXTRA_DOC, doc); 1077 1078 final FragmentTransaction ft = fm.beginTransaction(); 1079 AnimationView.setupAnimations(ft, anim, args); 1080 1081 final DirectoryFragment fragment = new DirectoryFragment(); 1082 fragment.setArguments(args); 1083 1084 ft.replace(getFragmentId(), fragment); 1085 ft.commitAllowingStateLoss(); 1086 } 1087 get(FragmentManager fm)1088 public static @Nullable DirectoryFragment get(FragmentManager fm) { 1089 // TODO: deal with multiple directories shown at once 1090 Fragment fragment = fm.findFragmentById(getFragmentId()); 1091 return fragment instanceof DirectoryFragment 1092 ? (DirectoryFragment) fragment 1093 : null; 1094 } 1095 getFragmentId()1096 private static int getFragmentId() { 1097 return R.id.container_directory; 1098 } 1099 1100 /** 1101 * Scroll to top of recyclerView in fragment 1102 */ scrollToTop()1103 public void scrollToTop() { 1104 if (mRecView != null) { 1105 mRecView.scrollToPosition(0); 1106 } 1107 } 1108 1109 /** 1110 * Stop the scroll of recyclerView in fragment 1111 */ stopScroll()1112 public void stopScroll() { 1113 if (mRecView != null) { 1114 mRecView.stopScroll(); 1115 } 1116 } 1117 1118 @Override onRefresh()1119 public void onRefresh() { 1120 // Remove thumbnail cache. We do this not because we're worried about stale thumbnails as it 1121 // should be covered by last modified value we store in thumbnail cache, but rather to give 1122 // the user a greater sense that contents are being reloaded. 1123 ThumbnailCache cache = DocumentsApplication.getThumbnailCache(getContext()); 1124 String[] ids = mModel.getModelIds(); 1125 int numOfEvicts = Math.min(ids.length, CACHE_EVICT_LIMIT); 1126 for (int i = 0; i < numOfEvicts; ++i) { 1127 cache.removeUri(mModel.getItemUri(ids[i])); 1128 } 1129 1130 final DocumentInfo doc = mActivity.getCurrentDirectory(); 1131 mActions.refreshDocument(doc, (boolean refreshSupported) -> { 1132 if (refreshSupported) { 1133 mRefreshLayout.setRefreshing(false); 1134 } else { 1135 // If Refresh API isn't available, we will explicitly reload the loader 1136 mActions.loadDocumentsForCurrentStack(); 1137 } 1138 }); 1139 } 1140 1141 private final class ModelUpdateListener implements EventListener<Model.Update> { 1142 1143 @Override accept(Model.Update update)1144 public void accept(Model.Update update) { 1145 if (DEBUG) { 1146 Log.d(TAG, "Received model update. Loading=" + mModel.isLoading()); 1147 } 1148 1149 mProgressBar.setVisibility(mModel.isLoading() ? View.VISIBLE : View.GONE); 1150 1151 updateLayout(mState.derivedMode); 1152 1153 // Update the selection to remove any disappeared IDs. 1154 Iterator<String> selectionIter = mSelectionMgr.getSelection().iterator(); 1155 while (selectionIter.hasNext()) { 1156 if (!mAdapter.getStableIds().contains(selectionIter.next())) { 1157 selectionIter.remove(); 1158 } 1159 } 1160 1161 mAdapter.notifyDataSetChanged(); 1162 1163 if (mRestoredState != null) { 1164 mSelectionMgr.onRestoreInstanceState(mRestoredState); 1165 mRestoredState = null; 1166 } 1167 1168 // Restore any previous instance state 1169 final SparseArray<Parcelable> container = 1170 mState.dirConfigs.remove(mLocalState.getConfigKey()); 1171 final int curSortedDimensionId = mState.sortModel.getSortedDimensionId(); 1172 1173 final SortDimension curSortedDimension = 1174 mState.sortModel.getDimensionById(curSortedDimensionId); 1175 1176 // Default not restore to avoid app bar layout expand to confuse users. 1177 if (container != null 1178 && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, true)) { 1179 getView().restoreHierarchyState(container); 1180 } else if (mLocalState.mLastSortDimensionId != curSortedDimension.getId() 1181 || mLocalState.mLastSortDimensionId == SortModel.SORT_DIMENSION_ID_UNKNOWN 1182 || mLocalState.mLastSortDirection != curSortedDimension.getSortDirection()) { 1183 // Scroll to the top if the sort order actually changed. 1184 mRecView.smoothScrollToPosition(0); 1185 } 1186 1187 mLocalState.mLastSortDimensionId = curSortedDimension.getId(); 1188 mLocalState.mLastSortDirection = curSortedDimension.getSortDirection(); 1189 1190 if (mRefreshLayout.isRefreshing()) { 1191 new Handler().postDelayed( 1192 () -> mRefreshLayout.setRefreshing(false), 1193 REFRESH_SPINNER_TIMEOUT); 1194 } 1195 1196 if (!mModel.isLoading()) { 1197 mActivity.notifyDirectoryLoaded( 1198 mModel.doc != null ? mModel.doc.derivedUri : null); 1199 // For orientation changed case, sometimes the docs loading comes after the menu 1200 // update. We need to update the menu here to ensure the status is correct. 1201 mInjector.menuManager.updateModel(mModel); 1202 mInjector.menuManager.updateOptionMenu(); 1203 1204 mActivity.updateHeaderTitle(); 1205 1206 setPreDrawListener(true); 1207 } 1208 } 1209 } 1210 1211 private final class AdapterEnvironment implements DocumentsAdapter.Environment { 1212 1213 @Override getFeatures()1214 public Features getFeatures() { 1215 return mInjector.features; 1216 } 1217 1218 @Override getContext()1219 public Context getContext() { 1220 return mActivity; 1221 } 1222 1223 @Override getDisplayState()1224 public State getDisplayState() { 1225 return mState; 1226 } 1227 1228 @Override isInSearchMode()1229 public boolean isInSearchMode() { 1230 return mInjector.searchManager.isSearching(); 1231 } 1232 1233 @Override getModel()1234 public Model getModel() { 1235 return mModel; 1236 } 1237 1238 @Override getColumnCount()1239 public int getColumnCount() { 1240 return mColumnCount; 1241 } 1242 1243 @Override isSelected(String id)1244 public boolean isSelected(String id) { 1245 return mSelectionMgr.isSelected(id); 1246 } 1247 1248 @Override isDocumentEnabled(String mimeType, int flags)1249 public boolean isDocumentEnabled(String mimeType, int flags) { 1250 return mInjector.config.isDocumentEnabled(mimeType, flags, mState); 1251 } 1252 1253 @Override initDocumentHolder(DocumentHolder holder)1254 public void initDocumentHolder(DocumentHolder holder) { 1255 holder.addKeyEventListener(mKeyListener); 1256 holder.itemView.setOnFocusChangeListener(mFocusManager); 1257 } 1258 1259 @Override onBindDocumentHolder(DocumentHolder holder, Cursor cursor)1260 public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) { 1261 setupDragAndDropOnDocumentView(holder.itemView, cursor); 1262 } 1263 1264 @Override getActionHandler()1265 public ActionHandler getActionHandler() { 1266 return mActions; 1267 } 1268 } 1269 } 1270