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.Shared.DEBUG; 20 import static com.android.documentsui.Shared.MAX_DOCS_IN_INTENT; 21 import static com.android.documentsui.State.MODE_GRID; 22 import static com.android.documentsui.State.MODE_LIST; 23 import static com.android.documentsui.State.SORT_ORDER_UNKNOWN; 24 import static com.android.documentsui.model.DocumentInfo.getCursorInt; 25 import static com.android.documentsui.model.DocumentInfo.getCursorString; 26 27 import android.annotation.IntDef; 28 import android.annotation.StringRes; 29 import android.app.Activity; 30 import android.app.ActivityManager; 31 import android.app.AlertDialog; 32 import android.app.Fragment; 33 import android.app.FragmentManager; 34 import android.app.FragmentTransaction; 35 import android.app.LoaderManager.LoaderCallbacks; 36 import android.content.ClipData; 37 import android.content.Context; 38 import android.content.DialogInterface; 39 import android.content.Intent; 40 import android.content.Loader; 41 import android.database.Cursor; 42 import android.graphics.Canvas; 43 import android.graphics.Point; 44 import android.graphics.Rect; 45 import android.graphics.drawable.Drawable; 46 import android.net.Uri; 47 import android.os.AsyncTask; 48 import android.os.Bundle; 49 import android.os.Parcel; 50 import android.os.Parcelable; 51 import android.provider.DocumentsContract; 52 import android.provider.DocumentsContract.Document; 53 import android.support.annotation.Nullable; 54 import android.support.design.widget.Snackbar; 55 import android.support.v13.view.DragStartHelper; 56 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; 57 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; 58 import android.support.v7.widget.GridLayoutManager; 59 import android.support.v7.widget.GridLayoutManager.SpanSizeLookup; 60 import android.support.v7.widget.RecyclerView; 61 import android.support.v7.widget.RecyclerView.OnItemTouchListener; 62 import android.support.v7.widget.RecyclerView.Recycler; 63 import android.support.v7.widget.RecyclerView.RecyclerListener; 64 import android.support.v7.widget.RecyclerView.ViewHolder; 65 import android.text.BidiFormatter; 66 import android.text.TextUtils; 67 import android.util.Log; 68 import android.util.SparseArray; 69 import android.view.ActionMode; 70 import android.view.DragEvent; 71 import android.view.GestureDetector; 72 import android.view.HapticFeedbackConstants; 73 import android.view.KeyEvent; 74 import android.view.LayoutInflater; 75 import android.view.Menu; 76 import android.view.MenuItem; 77 import android.view.MotionEvent; 78 import android.view.View; 79 import android.view.ViewGroup; 80 import android.widget.ImageView; 81 import android.widget.TextView; 82 import android.widget.Toolbar; 83 84 import com.android.documentsui.BaseActivity; 85 import com.android.documentsui.DirectoryLoader; 86 import com.android.documentsui.DirectoryResult; 87 import com.android.documentsui.DocumentClipper; 88 import com.android.documentsui.DocumentsActivity; 89 import com.android.documentsui.DocumentsApplication; 90 import com.android.documentsui.Events; 91 import com.android.documentsui.Events.MotionInputEvent; 92 import com.android.documentsui.Menus; 93 import com.android.documentsui.MessageBar; 94 import com.android.documentsui.Metrics; 95 import com.android.documentsui.MimePredicate; 96 import com.android.documentsui.R; 97 import com.android.documentsui.RecentsLoader; 98 import com.android.documentsui.RootsCache; 99 import com.android.documentsui.Shared; 100 import com.android.documentsui.Snackbars; 101 import com.android.documentsui.State; 102 import com.android.documentsui.State.ViewMode; 103 import com.android.documentsui.dirlist.MultiSelectManager.Selection; 104 import com.android.documentsui.model.DocumentInfo; 105 import com.android.documentsui.model.DocumentStack; 106 import com.android.documentsui.model.RootInfo; 107 import com.android.documentsui.services.FileOperationService; 108 import com.android.documentsui.services.FileOperationService.OpType; 109 import com.android.documentsui.services.FileOperations; 110 111 import com.google.common.collect.Lists; 112 113 import java.lang.annotation.Retention; 114 import java.lang.annotation.RetentionPolicy; 115 import java.util.ArrayList; 116 import java.util.Collections; 117 import java.util.HashSet; 118 import java.util.List; 119 import java.util.Objects; 120 import java.util.Set; 121 122 /** 123 * Display the documents inside a single directory. 124 */ 125 public class DirectoryFragment extends Fragment 126 implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult> { 127 128 @IntDef(flag = true, value = { 129 TYPE_NORMAL, 130 TYPE_RECENT_OPEN 131 }) 132 @Retention(RetentionPolicy.SOURCE) 133 public @interface ResultType {} 134 public static final int TYPE_NORMAL = 1; 135 public static final int TYPE_RECENT_OPEN = 2; 136 137 @IntDef(flag = true, value = { 138 REQUEST_COPY_DESTINATION 139 }) 140 @Retention(RetentionPolicy.SOURCE) 141 public @interface RequestCode {} 142 public static final int REQUEST_COPY_DESTINATION = 1; 143 144 private static final String TAG = "DirectoryFragment"; 145 private static final int LOADER_ID = 42; 146 147 private Model mModel; 148 private MultiSelectManager mSelectionManager; 149 private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener(); 150 private ItemEventListener mItemEventListener = new ItemEventListener(); 151 private FocusManager mFocusManager; 152 153 private IconHelper mIconHelper; 154 155 private View mEmptyView; 156 private RecyclerView mRecView; 157 private ListeningGestureDetector mGestureDetector; 158 159 private String mStateKey; 160 161 private int mLastSortOrder = SORT_ORDER_UNKNOWN; 162 private DocumentsAdapter mAdapter; 163 private FragmentTuner mTuner; 164 private DocumentClipper mClipper; 165 private GridLayoutManager mLayout; 166 private int mColumnCount = 1; // This will get updated when layout changes. 167 168 private LayoutInflater mInflater; 169 private MessageBar mMessageBar; 170 private View mProgressBar; 171 172 // Directory fragment state is defined by: root, document, query, type, selection 173 private @ResultType int mType = TYPE_NORMAL; 174 private RootInfo mRoot; 175 private DocumentInfo mDocument; 176 private String mQuery = null; 177 // Save selection found during creation so it can be restored during directory loading. 178 private Selection mSelection = null; 179 private boolean mSearchMode = false; 180 private @Nullable ActionMode mActionMode; 181 182 @Override onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)183 public View onCreateView( 184 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 185 mInflater = inflater; 186 final View view = inflater.inflate(R.layout.fragment_directory, container, false); 187 188 mMessageBar = MessageBar.create(getChildFragmentManager()); 189 mProgressBar = view.findViewById(R.id.progressbar); 190 mEmptyView = view.findViewById(android.R.id.empty); 191 mRecView = (RecyclerView) view.findViewById(R.id.dir_list); 192 mRecView.setRecyclerListener( 193 new RecyclerListener() { 194 @Override 195 public void onViewRecycled(ViewHolder holder) { 196 cancelThumbnailTask(holder.itemView); 197 } 198 }); 199 200 mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity())); 201 202 // Make the recycler and the empty views responsive to drop events. 203 mRecView.setOnDragListener(mOnDragListener); 204 mEmptyView.setOnDragListener(mOnDragListener); 205 206 return view; 207 } 208 209 @Override onDestroyView()210 public void onDestroyView() { 211 mSelectionManager.clearSelection(); 212 213 // Cancel any outstanding thumbnail requests 214 final int count = mRecView.getChildCount(); 215 for (int i = 0; i < count; i++) { 216 final View view = mRecView.getChildAt(i); 217 cancelThumbnailTask(view); 218 } 219 220 super.onDestroyView(); 221 } 222 223 @Override onActivityCreated(Bundle savedInstanceState)224 public void onActivityCreated(Bundle savedInstanceState) { 225 super.onActivityCreated(savedInstanceState); 226 227 final Context context = getActivity(); 228 final State state = getDisplayState(); 229 230 // Read arguments when object created for the first time. 231 // Restore state if fragment recreated. 232 Bundle args = savedInstanceState == null ? getArguments() : savedInstanceState; 233 mRoot = args.getParcelable(Shared.EXTRA_ROOT); 234 mDocument = args.getParcelable(Shared.EXTRA_DOC); 235 mStateKey = buildStateKey(mRoot, mDocument); 236 mQuery = args.getString(Shared.EXTRA_QUERY); 237 mType = args.getInt(Shared.EXTRA_TYPE); 238 final Selection selection = args.getParcelable(Shared.EXTRA_SELECTION); 239 mSelection = selection != null ? selection : new Selection(); 240 mSearchMode = args.getBoolean(Shared.EXTRA_SEARCH_MODE); 241 242 mIconHelper = new IconHelper(context, MODE_GRID); 243 244 mAdapter = new SectionBreakDocumentsAdapterWrapper( 245 this, new ModelBackedDocumentsAdapter(this, mIconHelper)); 246 247 mRecView.setAdapter(mAdapter); 248 249 // Switch Access Accessibility API needs an {@link AccessibilityDelegate} to know the proper 250 // route when user selects an UI element. It usually guesses this if the element has an 251 // {@link OnClickListener}, but since we do not have one for itemView, we will need to 252 // manually route it to the right behavior. RecyclerView has its own AccessibilityDelegate, 253 // and routes it to its LayoutManager; so we must override the LayoutManager's accessibility 254 // methods to route clicks correctly. 255 mLayout = new GridLayoutManager(getContext(), mColumnCount) { 256 @Override 257 public void onInitializeAccessibilityNodeInfoForItem( 258 RecyclerView.Recycler recycler, RecyclerView.State state, 259 View host, AccessibilityNodeInfoCompat info) { 260 super.onInitializeAccessibilityNodeInfoForItem(recycler, state, host, info); 261 info.addAction(AccessibilityActionCompat.ACTION_CLICK); 262 } 263 264 @Override 265 public boolean performAccessibilityActionForItem( 266 RecyclerView.Recycler recycler, RecyclerView.State state, View view, 267 int action, Bundle args) { 268 // We are only handling click events; route all other to default implementation 269 if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) { 270 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(view); 271 if (vh instanceof DocumentHolder) { 272 DocumentHolder dh = (DocumentHolder) vh; 273 if (dh.mEventListener != null) { 274 dh.mEventListener.onActivate(dh); 275 return true; 276 } 277 } 278 } 279 return super.performAccessibilityActionForItem(recycler, state, view, action, 280 args); 281 } 282 }; 283 SpanSizeLookup lookup = mAdapter.createSpanSizeLookup(); 284 if (lookup != null) { 285 mLayout.setSpanSizeLookup(lookup); 286 } 287 mRecView.setLayoutManager(mLayout); 288 289 mGestureDetector = 290 new ListeningGestureDetector(this.getContext(), mDragHelper, new GestureListener()); 291 292 mRecView.addOnItemTouchListener(mGestureDetector); 293 294 // TODO: instead of inserting the view into the constructor, extract listener-creation code 295 // and set the listener on the view after the fact. Then the view doesn't need to be passed 296 // into the selection manager. 297 mSelectionManager = new MultiSelectManager( 298 mRecView, 299 mAdapter, 300 state.allowMultiple 301 ? MultiSelectManager.MODE_MULTIPLE 302 : MultiSelectManager.MODE_SINGLE, 303 null); 304 305 mSelectionManager.addCallback(new SelectionModeListener()); 306 307 mModel = new Model(); 308 mModel.addUpdateListener(mAdapter); 309 mModel.addUpdateListener(mModelUpdateListener); 310 311 // Make sure this is done after the RecyclerView is set up. 312 mFocusManager = new FocusManager(context, mRecView, mModel); 313 314 mTuner = FragmentTuner.pick(getContext(), state); 315 mClipper = new DocumentClipper(context); 316 317 final ActivityManager am = (ActivityManager) context.getSystemService( 318 Context.ACTIVITY_SERVICE); 319 boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN); 320 mIconHelper.setThumbnailsEnabled(!svelte); 321 322 // Kick off loader at least once 323 getLoaderManager().restartLoader(LOADER_ID, null, this); 324 } 325 326 @Override onSaveInstanceState(Bundle outState)327 public void onSaveInstanceState(Bundle outState) { 328 super.onSaveInstanceState(outState); 329 330 mSelectionManager.getSelection(mSelection); 331 332 outState.putInt(Shared.EXTRA_TYPE, mType); 333 outState.putParcelable(Shared.EXTRA_ROOT, mRoot); 334 outState.putParcelable(Shared.EXTRA_DOC, mDocument); 335 outState.putString(Shared.EXTRA_QUERY, mQuery); 336 337 // Workaround. To avoid crash, write only up to 512 KB of selection. 338 // If more files are selected, then the selection will be lost. 339 final Parcel parcel = Parcel.obtain(); 340 try { 341 mSelection.writeToParcel(parcel, 0); 342 if (parcel.dataSize() <= 512 * 1024) { 343 outState.putParcelable(Shared.EXTRA_SELECTION, mSelection); 344 } 345 } finally { 346 parcel.recycle(); 347 } 348 349 outState.putBoolean(Shared.EXTRA_SEARCH_MODE, mSearchMode); 350 } 351 352 @Override onActivityResult(@equestCode int requestCode, int resultCode, Intent data)353 public void onActivityResult(@RequestCode int requestCode, int resultCode, Intent data) { 354 switch (requestCode) { 355 case REQUEST_COPY_DESTINATION: 356 handleCopyResult(resultCode, data); 357 break; 358 default: 359 throw new UnsupportedOperationException("Unknown request code: " + requestCode); 360 } 361 } 362 handleCopyResult(int resultCode, Intent data)363 private void handleCopyResult(int resultCode, Intent data) { 364 if (resultCode == Activity.RESULT_CANCELED || data == null) { 365 // User pressed the back button or otherwise cancelled the destination pick. Don't 366 // proceed with the copy. 367 return; 368 } 369 370 @OpType int operationType = data.getIntExtra( 371 FileOperationService.EXTRA_OPERATION, 372 FileOperationService.OPERATION_COPY); 373 374 FileOperations.start( 375 getActivity(), 376 getDisplayState().selectedDocumentsForCopy, 377 getDisplayState().stack.peek(), 378 (DocumentStack) data.getParcelableExtra(Shared.EXTRA_STACK), 379 operationType); 380 } 381 onDoubleTap(MotionEvent e)382 protected boolean onDoubleTap(MotionEvent e) { 383 if (Events.isMouseEvent(e)) { 384 String id = getModelId(e); 385 if (id != null) { 386 return handleViewItem(id); 387 } 388 } 389 return false; 390 } 391 handleViewItem(String id)392 private boolean handleViewItem(String id) { 393 final Cursor cursor = mModel.getItem(id); 394 395 if (cursor == null) { 396 Log.w(TAG, "Can't view item. Can't obtain cursor for modeId" + id); 397 return false; 398 } 399 400 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 401 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 402 if (mTuner.isDocumentEnabled(docMimeType, docFlags)) { 403 final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor); 404 ((BaseActivity) getActivity()).onDocumentPicked(doc, mModel); 405 mSelectionManager.clearSelection(); 406 return true; 407 } 408 return false; 409 } 410 411 @Override onStop()412 public void onStop() { 413 super.onStop(); 414 415 // Remember last scroll location 416 final SparseArray<Parcelable> container = new SparseArray<Parcelable>(); 417 getView().saveHierarchyState(container); 418 final State state = getDisplayState(); 419 state.dirState.put(mStateKey, container); 420 } 421 onDisplayStateChanged()422 public void onDisplayStateChanged() { 423 updateDisplayState(); 424 } 425 onSortOrderChanged()426 public void onSortOrderChanged() { 427 // Sort order is implemented as a sorting wrapper around directory 428 // results. So when sort order changes, we force a reload of the directory. 429 getLoaderManager().restartLoader(LOADER_ID, null, this); 430 } 431 onViewModeChanged()432 public void onViewModeChanged() { 433 // Mode change is just visual change; no need to kick loader. 434 updateDisplayState(); 435 } 436 updateDisplayState()437 private void updateDisplayState() { 438 State state = getDisplayState(); 439 updateLayout(state.derivedMode); 440 mRecView.setAdapter(mAdapter); 441 } 442 443 /** 444 * Updates the layout after the view mode switches. 445 * @param mode The new view mode. 446 */ updateLayout(@iewMode int mode)447 private void updateLayout(@ViewMode int mode) { 448 mColumnCount = calculateColumnCount(mode); 449 if (mLayout != null) { 450 mLayout.setSpanCount(mColumnCount); 451 } 452 453 int pad = getDirectoryPadding(mode); 454 mRecView.setPadding(pad, pad, pad, pad); 455 mRecView.requestLayout(); 456 mSelectionManager.handleLayoutChanged(); // RecyclerView doesn't do this for us 457 mIconHelper.setViewMode(mode); 458 } 459 calculateColumnCount(@iewMode int mode)460 private int calculateColumnCount(@ViewMode int mode) { 461 if (mode == MODE_LIST) { 462 // List mode is a "grid" with 1 column. 463 return 1; 464 } 465 466 int cellWidth = getResources().getDimensionPixelSize(R.dimen.grid_width); 467 int cellMargin = 2 * getResources().getDimensionPixelSize(R.dimen.grid_item_margin); 468 int viewPadding = mRecView.getPaddingLeft() + mRecView.getPaddingRight(); 469 470 // RecyclerView sometimes gets a width of 0 (see b/27150284). Clamp so that we always lay 471 // out the grid with at least 2 columns. 472 int columnCount = Math.max(2, 473 (mRecView.getWidth() - viewPadding) / (cellWidth + cellMargin)); 474 475 return columnCount; 476 } 477 getDirectoryPadding(@iewMode int mode)478 private int getDirectoryPadding(@ViewMode int mode) { 479 switch (mode) { 480 case MODE_GRID: 481 return getResources().getDimensionPixelSize(R.dimen.grid_container_padding); 482 case MODE_LIST: 483 return getResources().getDimensionPixelSize(R.dimen.list_container_padding); 484 default: 485 throw new IllegalArgumentException("Unsupported layout mode: " + mode); 486 } 487 } 488 489 @Override getColumnCount()490 public int getColumnCount() { 491 return mColumnCount; 492 } 493 494 /** 495 * Manages the integration between our ActionMode and MultiSelectManager, initiating 496 * ActionMode when there is a selection, canceling it when there is no selection, 497 * and clearing selection when action mode is explicitly exited by the user. 498 */ 499 private final class SelectionModeListener implements MultiSelectManager.Callback, 500 ActionMode.Callback, FragmentTuner.SelectionDetails { 501 502 private Selection mSelected = new Selection(); 503 504 // Partial files are files that haven't been fully downloaded. 505 private int mPartialCount = 0; 506 private int mDirectoryCount = 0; 507 private int mNoDeleteCount = 0; 508 private int mNoRenameCount = 0; 509 510 private Menu mMenu; 511 512 @Override onBeforeItemStateChange(String modelId, boolean selected)513 public boolean onBeforeItemStateChange(String modelId, boolean selected) { 514 if (selected) { 515 final Cursor cursor = mModel.getItem(modelId); 516 if (cursor == null) { 517 Log.w(TAG, "Can't obtain cursor for modelId: " + modelId); 518 return false; 519 } 520 521 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 522 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 523 if (!mTuner.canSelectType(docMimeType, docFlags)) { 524 return false; 525 } 526 527 if (mSelected.size() >= MAX_DOCS_IN_INTENT) { 528 Snackbars.makeSnackbar( 529 getActivity(), 530 R.string.too_many_selected, 531 Snackbar.LENGTH_SHORT) 532 .show(); 533 return false; 534 } 535 } 536 return true; 537 } 538 539 @Override onItemStateChanged(String modelId, boolean selected)540 public void onItemStateChanged(String modelId, boolean selected) { 541 final Cursor cursor = mModel.getItem(modelId); 542 if (cursor == null) { 543 Log.w(TAG, "Model returned null cursor for document: " + modelId 544 + ". Ignoring state changed event."); 545 return; 546 } 547 548 // TODO: Should this be happening in onSelectionChanged? Technically this callback is 549 // triggered on "silent" selection updates (i.e. we might be reacting to unfinalized 550 // selection changes here) 551 final String mimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 552 if (MimePredicate.isDirectoryType(mimeType)) { 553 mDirectoryCount += selected ? 1 : -1; 554 } 555 556 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 557 if ((docFlags & Document.FLAG_PARTIAL) != 0) { 558 mPartialCount += selected ? 1 : -1; 559 } 560 if ((docFlags & Document.FLAG_SUPPORTS_DELETE) == 0) { 561 mNoDeleteCount += selected ? 1 : -1; 562 } 563 if ((docFlags & Document.FLAG_SUPPORTS_RENAME) == 0) { 564 mNoRenameCount += selected ? 1 : -1; 565 } 566 } 567 568 @Override onSelectionChanged()569 public void onSelectionChanged() { 570 mSelectionManager.getSelection(mSelected); 571 if (mSelected.size() > 0) { 572 if (DEBUG) Log.d(TAG, "Maybe starting action mode."); 573 if (mActionMode == null) { 574 if (DEBUG) Log.d(TAG, "Yeah. Starting action mode."); 575 mActionMode = getActivity().startActionMode(this); 576 } 577 updateActionMenu(); 578 } else { 579 if (DEBUG) Log.d(TAG, "Finishing action mode."); 580 if (mActionMode != null) { 581 mActionMode.finish(); 582 } 583 } 584 585 if (mActionMode != null) { 586 assert(!mSelected.isEmpty()); 587 final String title = Shared.getQuantityString(getActivity(), 588 R.plurals.elements_selected, mSelected.size()); 589 mActionMode.setTitle(title); 590 mRecView.announceForAccessibility(title); 591 } 592 } 593 594 // Called when the user exits the action mode 595 @Override onDestroyActionMode(ActionMode mode)596 public void onDestroyActionMode(ActionMode mode) { 597 if (DEBUG) Log.d(TAG, "Handling action mode destroyed."); 598 mActionMode = null; 599 // clear selection 600 mSelectionManager.clearSelection(); 601 mSelected.clear(); 602 603 mDirectoryCount = 0; 604 mPartialCount = 0; 605 mNoDeleteCount = 0; 606 mNoRenameCount = 0; 607 608 // Re-enable TalkBack for the toolbars, as they are no longer covered by action mode. 609 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar); 610 toolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 611 612 // This toolbar is not present in the fixed_layout 613 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById(R.id.roots_toolbar); 614 if (rootsToolbar != null) { 615 rootsToolbar.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 616 } 617 } 618 619 @Override onCreateActionMode(ActionMode mode, Menu menu)620 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 621 mRecView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 622 623 int size = mSelectionManager.getSelection().size(); 624 mode.getMenuInflater().inflate(R.menu.mode_directory, menu); 625 mode.setTitle(TextUtils.formatSelectedCount(size)); 626 627 if (size > 0) { 628 // Hide the toolbars if action mode is enabled, so TalkBack doesn't navigate to 629 // these controls when using linear navigation. 630 final Toolbar toolbar = (Toolbar) getActivity().findViewById(R.id.toolbar); 631 toolbar.setImportantForAccessibility( 632 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 633 634 // This toolbar is not present in the fixed_layout 635 final Toolbar rootsToolbar = (Toolbar) getActivity().findViewById( 636 R.id.roots_toolbar); 637 if (rootsToolbar != null) { 638 rootsToolbar.setImportantForAccessibility( 639 View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 640 } 641 return true; 642 } 643 644 return false; 645 } 646 647 @Override onPrepareActionMode(ActionMode mode, Menu menu)648 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 649 mMenu = menu; 650 updateActionMenu(); 651 return true; 652 } 653 654 @Override containsDirectories()655 public boolean containsDirectories() { 656 return mDirectoryCount > 0; 657 } 658 659 @Override containsPartialFiles()660 public boolean containsPartialFiles() { 661 return mPartialCount > 0; 662 } 663 664 @Override canDelete()665 public boolean canDelete() { 666 return mNoDeleteCount == 0; 667 } 668 669 @Override canRename()670 public boolean canRename() { 671 return mNoRenameCount == 0 && mSelectionManager.getSelection().size() == 1; 672 } 673 updateActionMenu()674 private void updateActionMenu() { 675 assert(mMenu != null); 676 mTuner.updateActionMenu(mMenu, this); 677 Menus.disableHiddenItems(mMenu); 678 } 679 680 @Override onActionItemClicked(ActionMode mode, MenuItem item)681 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 682 Selection selection = mSelectionManager.getSelection(new Selection()); 683 684 switch (item.getItemId()) { 685 case R.id.menu_open: 686 openDocuments(selection); 687 mode.finish(); 688 return true; 689 690 case R.id.menu_share: 691 shareDocuments(selection); 692 // TODO: Only finish selection if share action is completed. 693 mode.finish(); 694 return true; 695 696 case R.id.menu_delete: 697 // deleteDocuments will end action mode if the documents are deleted. 698 // It won't end action mode if user cancels the delete. 699 deleteDocuments(selection); 700 return true; 701 702 case R.id.menu_copy_to: 703 // TODO: Only finish selection mode if copy-to is not canceled. 704 // Need to plum down into handling the way we do with deleteDocuments. 705 mode.finish(); 706 transferDocuments(selection, FileOperationService.OPERATION_COPY); 707 return true; 708 709 case R.id.menu_move_to: 710 // Exit selection mode first, so we avoid deselecting deleted documents. 711 mode.finish(); 712 transferDocuments(selection, FileOperationService.OPERATION_MOVE); 713 return true; 714 715 case R.id.menu_copy_to_clipboard: 716 copySelectedToClipboard(); 717 return true; 718 719 case R.id.menu_select_all: 720 selectAllFiles(); 721 return true; 722 723 case R.id.menu_rename: 724 // Exit selection mode first, so we avoid deselecting deleted 725 // (renamed) documents. 726 mode.finish(); 727 renameDocuments(selection); 728 return true; 729 730 default: 731 if (DEBUG) Log.d(TAG, "Unhandled menu item selected: " + item); 732 return false; 733 } 734 } 735 } 736 onBackPressed()737 public final boolean onBackPressed() { 738 if (mSelectionManager.hasSelection()) { 739 if (DEBUG) Log.d(TAG, "Clearing selection on selection manager."); 740 mSelectionManager.clearSelection(); 741 return true; 742 } 743 return false; 744 } 745 cancelThumbnailTask(View view)746 private void cancelThumbnailTask(View view) { 747 final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb); 748 if (iconThumb != null) { 749 mIconHelper.stopLoading(iconThumb); 750 } 751 } 752 openDocuments(final Selection selected)753 private void openDocuments(final Selection selected) { 754 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_OPEN); 755 756 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 757 List<DocumentInfo> docs = mModel.getDocuments(selected); 758 // TODO: Implement support in Files activity for opening multiple docs. 759 BaseActivity.get(DirectoryFragment.this).onDocumentsPicked(docs); 760 } 761 shareDocuments(final Selection selected)762 private void shareDocuments(final Selection selected) { 763 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SHARE); 764 765 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 766 List<DocumentInfo> docs = mModel.getDocuments(selected); 767 Intent intent; 768 769 // Filter out directories and virtual files - those can't be shared. 770 List<DocumentInfo> docsForSend = new ArrayList<>(); 771 for (DocumentInfo doc: docs) { 772 if (!doc.isDirectory() && !doc.isVirtualDocument()) { 773 docsForSend.add(doc); 774 } 775 } 776 777 if (docsForSend.size() == 1) { 778 final DocumentInfo doc = docsForSend.get(0); 779 780 intent = new Intent(Intent.ACTION_SEND); 781 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 782 intent.addCategory(Intent.CATEGORY_DEFAULT); 783 intent.setType(doc.mimeType); 784 intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri); 785 786 } else if (docsForSend.size() > 1) { 787 intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 788 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 789 intent.addCategory(Intent.CATEGORY_DEFAULT); 790 791 final ArrayList<String> mimeTypes = new ArrayList<>(); 792 final ArrayList<Uri> uris = new ArrayList<>(); 793 for (DocumentInfo doc : docsForSend) { 794 mimeTypes.add(doc.mimeType); 795 uris.add(doc.derivedUri); 796 } 797 798 intent.setType(findCommonMimeType(mimeTypes)); 799 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); 800 801 } else { 802 return; 803 } 804 805 intent = Intent.createChooser(intent, getActivity().getText(R.string.share_via)); 806 startActivity(intent); 807 } 808 generateDeleteMessage(final List<DocumentInfo> docs)809 private String generateDeleteMessage(final List<DocumentInfo> docs) { 810 String message; 811 int dirsCount = 0; 812 813 for (DocumentInfo doc : docs) { 814 if (doc.isDirectory()) { 815 ++dirsCount; 816 } 817 } 818 819 if (docs.size() == 1) { 820 // Deleteing 1 file xor 1 folder in cwd 821 822 // Address b/28772371, where including user strings in message can result in 823 // broken bidirectional support. 824 String displayName = BidiFormatter.getInstance().unicodeWrap(docs.get(0).displayName); 825 message = dirsCount == 0 826 ? getActivity().getString(R.string.delete_filename_confirmation_message, 827 displayName) 828 : getActivity().getString(R.string.delete_foldername_confirmation_message, 829 displayName); 830 } else if (dirsCount == 0) { 831 // Deleting only files in cwd 832 message = Shared.getQuantityString(getActivity(), 833 R.plurals.delete_files_confirmation_message, docs.size()); 834 } else if (dirsCount == docs.size()) { 835 // Deleting only folders in cwd 836 message = Shared.getQuantityString(getActivity(), 837 R.plurals.delete_folders_confirmation_message, docs.size()); 838 } else { 839 // Deleting mixed items (files and folders) in cwd 840 message = Shared.getQuantityString(getActivity(), 841 R.plurals.delete_items_confirmation_message, docs.size()); 842 } 843 return message; 844 } 845 deleteDocuments(final Selection selected)846 private void deleteDocuments(final Selection selected) { 847 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_DELETE); 848 849 assert(!selected.isEmpty()); 850 851 final DocumentInfo srcParent = getDisplayState().stack.peek(); 852 853 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 854 List<DocumentInfo> docs = mModel.getDocuments(selected); 855 856 TextView message = 857 (TextView) mInflater.inflate(R.layout.dialog_delete_confirmation, null); 858 message.setText(generateDeleteMessage(docs)); 859 860 // This "insta-hides" files that are being deleted, because 861 // the delete operation may be not execute immediately (it 862 // may be queued up on the FileOperationService.) 863 // To hide the files locally, we call the hide method on the adapter 864 // ...which a live object...cannot be parceled. 865 // For that reason, for now, we implement this dialog NOT 866 // as a fragment (which can survive rotation and have its own state), 867 // but as a simple runtime dialog. So rotating a device with an 868 // active delete dialog...results in that dialog disappearing. 869 // We can do better, but don't have cycles for it now. 870 new AlertDialog.Builder(getActivity()) 871 .setView(message) 872 .setPositiveButton( 873 android.R.string.yes, 874 new DialogInterface.OnClickListener() { 875 @Override 876 public void onClick(DialogInterface dialog, int id) { 877 // Finish selection mode first which clears selection so we 878 // don't end up trying to deselect deleted documents. 879 // This is done here, rather in the onActionItemClicked 880 // so we can avoid de-selecting items in the case where 881 // the user cancels the delete. 882 if (mActionMode != null) { 883 mActionMode.finish(); 884 } else { 885 Log.w(TAG, "Action mode is null before deleting documents."); 886 } 887 // Hide the files in the UI...since the operation 888 // might be queued up on FileOperationService. 889 // We're walking a line here. 890 mAdapter.hide(selected.getAll()); 891 FileOperations.delete( 892 getActivity(), docs, srcParent, getDisplayState().stack); 893 } 894 }) 895 .setNegativeButton(android.R.string.no, null) 896 .show(); 897 } 898 transferDocuments(final Selection selected, final @OpType int mode)899 private void transferDocuments(final Selection selected, final @OpType int mode) { 900 if(mode == FileOperationService.OPERATION_COPY) { 901 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_TO); 902 } else if (mode == FileOperationService.OPERATION_MOVE) { 903 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_MOVE_TO); 904 } 905 906 // Pop up a dialog to pick a destination. This is inadequate but works for now. 907 // TODO: Implement a picker that is to spec. 908 final Intent intent = new Intent( 909 Shared.ACTION_PICK_COPY_DESTINATION, 910 Uri.EMPTY, 911 getActivity(), 912 DocumentsActivity.class); 913 914 915 // Relay any config overrides bits present in the original intent. 916 Intent original = getActivity().getIntent(); 917 if (original != null && original.hasExtra(Shared.EXTRA_PRODUCTIVITY_MODE)) { 918 intent.putExtra( 919 Shared.EXTRA_PRODUCTIVITY_MODE, 920 original.getBooleanExtra(Shared.EXTRA_PRODUCTIVITY_MODE, false)); 921 } 922 923 // Set an appropriate title on the drawer when it is shown in the picker. 924 // Coupled with the fact that we auto-open the drawer for copy/move operations 925 // it should basically be the thing people see first. 926 int drawerTitleId = mode == FileOperationService.OPERATION_MOVE 927 ? R.string.menu_move : R.string.menu_copy; 928 intent.putExtra(DocumentsContract.EXTRA_PROMPT, getResources().getString(drawerTitleId)); 929 930 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 931 List<DocumentInfo> docs = mModel.getDocuments(selected); 932 // TODO: Can this move to Fragment bundle state? 933 getDisplayState().selectedDocumentsForCopy = docs; 934 935 // Determine if there is a directory in the set of documents 936 // to be copied? Why? Directory creation isn't supported by some roots 937 // (like Downloads). This informs DocumentsActivity (the "picker") 938 // to restrict available roots to just those with support. 939 intent.putExtra(Shared.EXTRA_DIRECTORY_COPY, hasDirectory(docs)); 940 intent.putExtra(FileOperationService.EXTRA_OPERATION, mode); 941 942 // This just identifies the type of request...we'll check it 943 // when we reveive a response. 944 startActivityForResult(intent, REQUEST_COPY_DESTINATION); 945 } 946 hasDirectory(List<DocumentInfo> docs)947 private static boolean hasDirectory(List<DocumentInfo> docs) { 948 for (DocumentInfo info : docs) { 949 if (Document.MIME_TYPE_DIR.equals(info.mimeType)) { 950 return true; 951 } 952 } 953 return false; 954 } 955 renameDocuments(Selection selected)956 private void renameDocuments(Selection selected) { 957 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_RENAME); 958 959 // Batch renaming not supported 960 // Rename option is only available in menu when 1 document selected 961 assert(selected.size() == 1); 962 963 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 964 List<DocumentInfo> docs = mModel.getDocuments(selected); 965 RenameDocumentFragment.show(getFragmentManager(), docs.get(0)); 966 } 967 968 @Override initDocumentHolder(DocumentHolder holder)969 public void initDocumentHolder(DocumentHolder holder) { 970 holder.addEventListener(mItemEventListener); 971 holder.itemView.setOnFocusChangeListener(mFocusManager); 972 } 973 974 @Override onBindDocumentHolder(DocumentHolder holder, Cursor cursor)975 public void onBindDocumentHolder(DocumentHolder holder, Cursor cursor) { 976 setupDragAndDropOnDocumentView(holder.itemView, cursor); 977 } 978 979 @Override getDisplayState()980 public State getDisplayState() { 981 return ((BaseActivity) getActivity()).getDisplayState(); 982 } 983 984 @Override getModel()985 public Model getModel() { 986 return mModel; 987 } 988 989 @Override isDocumentEnabled(String docMimeType, int docFlags)990 public boolean isDocumentEnabled(String docMimeType, int docFlags) { 991 return mTuner.isDocumentEnabled(docMimeType, docFlags); 992 } 993 showEmptyDirectory()994 private void showEmptyDirectory() { 995 showEmptyView(R.string.empty, R.drawable.cabinet); 996 } 997 showNoResults(RootInfo root)998 private void showNoResults(RootInfo root) { 999 CharSequence msg = getContext().getResources().getText(R.string.no_results); 1000 showEmptyView(String.format(String.valueOf(msg), root.title), R.drawable.cabinet); 1001 } 1002 showQueryError()1003 private void showQueryError() { 1004 showEmptyView(R.string.query_error, R.drawable.hourglass); 1005 } 1006 showEmptyView(@tringRes int id, int drawable)1007 private void showEmptyView(@StringRes int id, int drawable) { 1008 showEmptyView(getContext().getResources().getText(id), drawable); 1009 } 1010 showEmptyView(CharSequence msg, int drawable)1011 private void showEmptyView(CharSequence msg, int drawable) { 1012 View content = mEmptyView.findViewById(R.id.content); 1013 TextView msgView = (TextView) mEmptyView.findViewById(R.id.message); 1014 ImageView imageView = (ImageView) mEmptyView.findViewById(R.id.artwork); 1015 msgView.setText(msg); 1016 imageView.setImageResource(drawable); 1017 1018 mEmptyView.setVisibility(View.VISIBLE); 1019 mEmptyView.requestFocus(); 1020 mRecView.setVisibility(View.GONE); 1021 } 1022 showDirectory()1023 private void showDirectory() { 1024 mEmptyView.setVisibility(View.GONE); 1025 mRecView.setVisibility(View.VISIBLE); 1026 mRecView.requestFocus(); 1027 } 1028 findCommonMimeType(List<String> mimeTypes)1029 private String findCommonMimeType(List<String> mimeTypes) { 1030 String[] commonType = mimeTypes.get(0).split("/"); 1031 if (commonType.length != 2) { 1032 return "*/*"; 1033 } 1034 1035 for (int i = 1; i < mimeTypes.size(); i++) { 1036 String[] type = mimeTypes.get(i).split("/"); 1037 if (type.length != 2) continue; 1038 1039 if (!commonType[1].equals(type[1])) { 1040 commonType[1] = "*"; 1041 } 1042 1043 if (!commonType[0].equals(type[0])) { 1044 commonType[0] = "*"; 1045 commonType[1] = "*"; 1046 break; 1047 } 1048 } 1049 1050 return commonType[0] + "/" + commonType[1]; 1051 } 1052 copyFromClipboard()1053 private void copyFromClipboard() { 1054 new AsyncTask<Void, Void, List<DocumentInfo>>() { 1055 1056 @Override 1057 protected List<DocumentInfo> doInBackground(Void... params) { 1058 return mClipper.getClippedDocuments(); 1059 } 1060 1061 @Override 1062 protected void onPostExecute(List<DocumentInfo> docs) { 1063 DocumentInfo destination = 1064 ((BaseActivity) getActivity()).getCurrentDirectory(); 1065 copyDocuments(docs, destination); 1066 } 1067 }.execute(); 1068 } 1069 copyFromClipData(final ClipData clipData, final DocumentInfo destination)1070 private void copyFromClipData(final ClipData clipData, final DocumentInfo destination) { 1071 assert(clipData != null); 1072 1073 new AsyncTask<Void, Void, List<DocumentInfo>>() { 1074 1075 @Override 1076 protected List<DocumentInfo> doInBackground(Void... params) { 1077 return mClipper.getDocumentsFromClipData(clipData); 1078 } 1079 1080 @Override 1081 protected void onPostExecute(List<DocumentInfo> docs) { 1082 copyDocuments(docs, destination); 1083 } 1084 }.execute(); 1085 } 1086 copyDocuments(final List<DocumentInfo> docs, final DocumentInfo destination)1087 private void copyDocuments(final List<DocumentInfo> docs, final DocumentInfo destination) { 1088 BaseActivity activity = (BaseActivity) getActivity(); 1089 if (!canCopy(docs, activity.getCurrentRoot(), destination)) { 1090 Snackbars.makeSnackbar( 1091 getActivity(), 1092 R.string.clipboard_files_cannot_paste, 1093 Snackbar.LENGTH_SHORT) 1094 .show(); 1095 return; 1096 } 1097 1098 if (docs.isEmpty()) { 1099 return; 1100 } 1101 1102 final DocumentStack curStack = getDisplayState().stack; 1103 DocumentStack tmpStack = new DocumentStack(); 1104 if (destination != null) { 1105 tmpStack.push(destination); 1106 tmpStack.addAll(curStack); 1107 } else { 1108 tmpStack = curStack; 1109 } 1110 1111 FileOperations.copy(getActivity(), docs, tmpStack); 1112 } 1113 copySelectedToClipboard()1114 public void copySelectedToClipboard() { 1115 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_CLIPBOARD); 1116 1117 Selection selection = mSelectionManager.getSelection(new Selection()); 1118 if (!selection.isEmpty()) { 1119 copySelectionToClipboard(selection); 1120 mSelectionManager.clearSelection(); 1121 } 1122 } 1123 copySelectionToClipboard(Selection selected)1124 void copySelectionToClipboard(Selection selected) { 1125 assert(!selected.isEmpty()); 1126 1127 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 1128 List<DocumentInfo> docs = mModel.getDocuments(selected); 1129 mClipper.clipDocuments(docs); 1130 Activity activity = getActivity(); 1131 Snackbars.makeSnackbar(activity, 1132 activity.getResources().getQuantityString( 1133 R.plurals.clipboard_files_clipped, docs.size(), docs.size()), 1134 Snackbar.LENGTH_SHORT).show(); 1135 } 1136 pasteFromClipboard()1137 public void pasteFromClipboard() { 1138 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD); 1139 1140 copyFromClipboard(); 1141 getActivity().invalidateOptionsMenu(); 1142 } 1143 1144 /** 1145 * Returns true if the list of files can be copied to destination. Note that this 1146 * is a policy check only. Currently the method does not attempt to verify 1147 * available space or any other environmental aspects possibly resulting in 1148 * failure to copy. 1149 * 1150 * @return true if the list of files can be copied to destination. 1151 */ canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest)1152 private boolean canCopy(List<DocumentInfo> files, RootInfo root, DocumentInfo dest) { 1153 if (dest == null || !dest.isDirectory() || !dest.isCreateSupported()) { 1154 return false; 1155 } 1156 1157 // Can't copy folders to downloads, because we don't show folders there. 1158 if (root.isDownloads()) { 1159 for (DocumentInfo docs : files) { 1160 if (docs.isDirectory()) { 1161 return false; 1162 } 1163 } 1164 } 1165 1166 return true; 1167 } 1168 selectAllFiles()1169 public void selectAllFiles() { 1170 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SELECT_ALL); 1171 1172 // Exclude disabled files. 1173 Set<String> enabled = new HashSet<String>(); 1174 List<String> modelIds = mAdapter.getModelIds(); 1175 1176 // Get the current selection. 1177 String[] alreadySelected = mSelectionManager.getSelection().getAll(); 1178 for (String id : alreadySelected) { 1179 enabled.add(id); 1180 } 1181 1182 for (String id : modelIds) { 1183 Cursor cursor = getModel().getItem(id); 1184 if (cursor == null) { 1185 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id); 1186 continue; 1187 } 1188 String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1189 int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 1190 if (mTuner.canSelectType(docMimeType, docFlags)) { 1191 if (enabled.size() >= MAX_DOCS_IN_INTENT) { 1192 Snackbars.makeSnackbar( 1193 getActivity(), 1194 R.string.too_many_in_select_all, 1195 Snackbar.LENGTH_SHORT) 1196 .show(); 1197 break; 1198 } 1199 enabled.add(id); 1200 } 1201 } 1202 1203 // Only select things currently visible in the adapter. 1204 boolean changed = mSelectionManager.setItemsSelected(enabled, true); 1205 if (changed) { 1206 updateDisplayState(); 1207 } 1208 } 1209 1210 /** 1211 * Attempts to restore focus on the directory listing. 1212 */ requestFocus()1213 public void requestFocus() { 1214 mFocusManager.restoreLastFocus(); 1215 } 1216 setupDragAndDropOnDocumentView(View view, Cursor cursor)1217 private void setupDragAndDropOnDocumentView(View view, Cursor cursor) { 1218 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1219 if (Document.MIME_TYPE_DIR.equals(docMimeType)) { 1220 // Make a directory item a drop target. Drop on non-directories and empty space 1221 // is handled at the list/grid view level. 1222 view.setOnDragListener(mOnDragListener); 1223 } 1224 1225 if (mTuner.dragAndDropEnabled()) { 1226 // Make all items draggable. 1227 view.setOnLongClickListener(onLongClickListener); 1228 } 1229 } 1230 1231 private View.OnDragListener mOnDragListener = new View.OnDragListener() { 1232 @Override 1233 public boolean onDrag(View v, DragEvent event) { 1234 switch (event.getAction()) { 1235 case DragEvent.ACTION_DRAG_STARTED: 1236 // TODO: Check if the event contains droppable data. 1237 return true; 1238 1239 // TODO: Expand drop target directory on hover? 1240 case DragEvent.ACTION_DRAG_ENTERED: 1241 setDropTargetHighlight(v, true); 1242 return true; 1243 case DragEvent.ACTION_DRAG_EXITED: 1244 setDropTargetHighlight(v, false); 1245 return true; 1246 1247 case DragEvent.ACTION_DRAG_LOCATION: 1248 return true; 1249 1250 case DragEvent.ACTION_DRAG_ENDED: 1251 if (event.getResult()) { 1252 // Exit selection mode if the drop was handled. 1253 mSelectionManager.clearSelection(); 1254 } 1255 return true; 1256 1257 case DragEvent.ACTION_DROP: 1258 // After a drop event, always stop highlighting the target. 1259 setDropTargetHighlight(v, false); 1260 1261 ClipData clipData = event.getClipData(); 1262 if (clipData == null) { 1263 Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring."); 1264 return false; 1265 } 1266 1267 // Don't copy from the cwd into the cwd. Note: this currently doesn't work for 1268 // multi-window drag, because localState isn't carried over from one process to 1269 // another. 1270 Object src = event.getLocalState(); 1271 DocumentInfo dst = getDestination(v); 1272 if (Objects.equals(src, dst)) { 1273 if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring."); 1274 return false; 1275 } 1276 1277 // Recognize multi-window drag and drop based on the fact that localState is not 1278 // carried between processes. It will stop working when the localsState behavior 1279 // is changed. The info about window should be passed in the localState then. 1280 // The localState could also be null for copying from Recents in single window 1281 // mode, but Recents doesn't offer this functionality (no directories). 1282 Metrics.logUserAction(getContext(), 1283 src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW 1284 : Metrics.USER_ACTION_DRAG_N_DROP); 1285 1286 copyFromClipData(clipData, dst); 1287 return true; 1288 } 1289 return false; 1290 } 1291 1292 private DocumentInfo getDestination(View v) { 1293 String id = getModelId(v); 1294 if (id != null) { 1295 Cursor dstCursor = mModel.getItem(id); 1296 if (dstCursor == null) { 1297 Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id); 1298 return null; 1299 } 1300 return DocumentInfo.fromDirectoryCursor(dstCursor); 1301 } 1302 1303 if (v == mRecView || v == mEmptyView) { 1304 return getDisplayState().stack.peek(); 1305 } 1306 1307 return null; 1308 } 1309 1310 private void setDropTargetHighlight(View v, boolean highlight) { 1311 // Note: use exact comparison - this code is searching for views which are children of 1312 // the RecyclerView instance in the UI. 1313 if (v.getParent() == mRecView) { 1314 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v); 1315 if (vh instanceof DocumentHolder) { 1316 ((DocumentHolder) vh).setHighlighted(highlight); 1317 } 1318 } 1319 } 1320 }; 1321 1322 /** 1323 * Gets the model ID for a given motion event (using the event position) 1324 */ getModelId(MotionEvent e)1325 private String getModelId(MotionEvent e) { 1326 View view = mRecView.findChildViewUnder(e.getX(), e.getY()); 1327 if (view == null) { 1328 return null; 1329 } 1330 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(view); 1331 if (vh instanceof DocumentHolder) { 1332 return ((DocumentHolder) vh).modelId; 1333 } else { 1334 return null; 1335 } 1336 } 1337 1338 /** 1339 * Gets the model ID for a given RecyclerView item. 1340 * @param view A View that is a document item view, or a child of a document item view. 1341 * @return The Model ID for the given document, or null if the given view is not associated with 1342 * a document item view. 1343 */ getModelId(View view)1344 private String getModelId(View view) { 1345 View itemView = mRecView.findContainingItemView(view); 1346 if (itemView != null) { 1347 RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView); 1348 if (vh instanceof DocumentHolder) { 1349 return ((DocumentHolder) vh).modelId; 1350 } 1351 } 1352 return null; 1353 } 1354 getDraggableDocuments(View currentItemView)1355 private List<DocumentInfo> getDraggableDocuments(View currentItemView) { 1356 String modelId = getModelId(currentItemView); 1357 if (modelId == null) { 1358 return Collections.EMPTY_LIST; 1359 } 1360 1361 final List<DocumentInfo> selectedDocs = 1362 mModel.getDocuments(mSelectionManager.getSelection()); 1363 if (!selectedDocs.isEmpty()) { 1364 if (!isSelected(modelId)) { 1365 // There is a selection that does not include the current item, drag nothing. 1366 return Collections.EMPTY_LIST; 1367 } 1368 return selectedDocs; 1369 } 1370 1371 final Cursor cursor = mModel.getItem(modelId); 1372 if (cursor == null) { 1373 Log.w(TAG, "Undraggable document. Can't obtain cursor for modelId " + modelId); 1374 return Collections.EMPTY_LIST; 1375 } 1376 1377 return Lists.newArrayList( 1378 DocumentInfo.fromDirectoryCursor(cursor)); 1379 } 1380 1381 private static class DragShadowBuilder extends View.DragShadowBuilder { 1382 1383 private final Context mContext; 1384 private final IconHelper mIconHelper; 1385 private final LayoutInflater mInflater; 1386 private final View mShadowView; 1387 private final TextView mTitle; 1388 private final ImageView mIcon; 1389 private final int mWidth; 1390 private final int mHeight; 1391 DragShadowBuilder(Context context, IconHelper iconHelper, List<DocumentInfo> docs)1392 public DragShadowBuilder(Context context, IconHelper iconHelper, List<DocumentInfo> docs) { 1393 mContext = context; 1394 mIconHelper = iconHelper; 1395 mInflater = LayoutInflater.from(context); 1396 1397 mWidth = mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_width); 1398 mHeight= mContext.getResources().getDimensionPixelSize(R.dimen.drag_shadow_height); 1399 1400 mShadowView = mInflater.inflate(R.layout.drag_shadow_layout, null); 1401 mTitle = (TextView) mShadowView.findViewById(android.R.id.title); 1402 mIcon = (ImageView) mShadowView.findViewById(android.R.id.icon); 1403 1404 mTitle.setText(getTitle(docs)); 1405 mIcon.setImageDrawable(getIcon(docs)); 1406 } 1407 getIcon(List<DocumentInfo> docs)1408 private Drawable getIcon(List<DocumentInfo> docs) { 1409 if (docs.size() == 1) { 1410 final DocumentInfo doc = docs.get(0); 1411 return mIconHelper.getDocumentIcon(mContext, doc.authority, doc.documentId, 1412 doc.mimeType, doc.icon); 1413 } 1414 return mContext.getDrawable(com.android.internal.R.drawable.ic_doc_generic); 1415 } 1416 getTitle(List<DocumentInfo> docs)1417 private String getTitle(List<DocumentInfo> docs) { 1418 if (docs.size() == 1) { 1419 final DocumentInfo doc = docs.get(0); 1420 return doc.displayName; 1421 } 1422 return Shared.getQuantityString(mContext, R.plurals.elements_dragged, docs.size()); 1423 } 1424 1425 @Override onProvideShadowMetrics( Point shadowSize, Point shadowTouchPoint)1426 public void onProvideShadowMetrics( 1427 Point shadowSize, Point shadowTouchPoint) { 1428 shadowSize.set(mWidth, mHeight); 1429 shadowTouchPoint.set(mWidth, mHeight); 1430 } 1431 1432 @Override onDrawShadow(Canvas canvas)1433 public void onDrawShadow(Canvas canvas) { 1434 Rect r = canvas.getClipBounds(); 1435 // Calling measure is necessary in order for all child views to get correctly laid out. 1436 mShadowView.measure( 1437 View.MeasureSpec.makeMeasureSpec(r.right- r.left, View.MeasureSpec.EXACTLY), 1438 View.MeasureSpec.makeMeasureSpec(r.top- r.bottom, View.MeasureSpec.EXACTLY)); 1439 mShadowView.layout(r.left, r.top, r.right, r.bottom); 1440 mShadowView.draw(canvas); 1441 } 1442 } 1443 1444 @Override isSelected(String modelId)1445 public boolean isSelected(String modelId) { 1446 return mSelectionManager.getSelection().contains(modelId); 1447 } 1448 1449 private class ItemEventListener implements DocumentHolder.EventListener { 1450 @Override onActivate(DocumentHolder doc)1451 public boolean onActivate(DocumentHolder doc) { 1452 // Toggle selection if we're in selection mode, othewise, view item. 1453 if (mSelectionManager.hasSelection()) { 1454 mSelectionManager.toggleSelection(doc.modelId); 1455 } else { 1456 handleViewItem(doc.modelId); 1457 } 1458 return true; 1459 } 1460 1461 @Override onSelect(DocumentHolder doc)1462 public boolean onSelect(DocumentHolder doc) { 1463 mSelectionManager.toggleSelection(doc.modelId); 1464 mSelectionManager.setSelectionRangeBegin(doc.getAdapterPosition()); 1465 return true; 1466 } 1467 1468 @Override onKey(DocumentHolder doc, int keyCode, KeyEvent event)1469 public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) { 1470 // Only handle key-down events. This is simpler, consistent with most other UIs, and 1471 // enables the handling of repeated key events from holding down a key. 1472 if (event.getAction() != KeyEvent.ACTION_DOWN) { 1473 return false; 1474 } 1475 1476 // Ignore tab key events. Those should be handled by the top-level key handler. 1477 if (keyCode == KeyEvent.KEYCODE_TAB) { 1478 return false; 1479 } 1480 1481 if (mFocusManager.handleKey(doc, keyCode, event)) { 1482 // Handle range selection adjustments. Extending the selection will adjust the 1483 // bounds of the in-progress range selection. Each time an unshifted navigation 1484 // event is received, the range selection is restarted. 1485 if (shouldExtendSelection(doc, event)) { 1486 if (!mSelectionManager.isRangeSelectionActive()) { 1487 // Start a range selection if one isn't active 1488 mSelectionManager.startRangeSelection(doc.getAdapterPosition()); 1489 } 1490 mSelectionManager.snapRangeSelection(mFocusManager.getFocusPosition()); 1491 } else { 1492 mSelectionManager.endRangeSelection(); 1493 } 1494 return true; 1495 } 1496 1497 // Handle enter key events 1498 switch (keyCode) { 1499 case KeyEvent.KEYCODE_ENTER: 1500 if (event.isShiftPressed()) { 1501 return onSelect(doc); 1502 } 1503 // For non-shifted enter keypresses, fall through. 1504 case KeyEvent.KEYCODE_DPAD_CENTER: 1505 case KeyEvent.KEYCODE_BUTTON_A: 1506 return onActivate(doc); 1507 case KeyEvent.KEYCODE_FORWARD_DEL: 1508 // This has to be handled here instead of in a keyboard shortcut, because 1509 // keyboard shortcuts all have to be modified with the 'Ctrl' key. 1510 if (mSelectionManager.hasSelection()) { 1511 Selection selection = mSelectionManager.getSelection(new Selection()); 1512 deleteDocuments(selection); 1513 } 1514 // Always handle the key, even if there was nothing to delete. This is a 1515 // precaution to prevent other handlers from potentially picking up the event 1516 // and triggering extra behaviours. 1517 return true; 1518 } 1519 1520 return false; 1521 } 1522 shouldExtendSelection(DocumentHolder doc, KeyEvent event)1523 private boolean shouldExtendSelection(DocumentHolder doc, KeyEvent event) { 1524 if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) { 1525 return false; 1526 } 1527 1528 // TODO: Combine this method with onBeforeItemStateChange, as both of them are almost 1529 // the same, and responsible for the same thing (whether to select or not). 1530 final Cursor cursor = mModel.getItem(doc.modelId); 1531 if (cursor == null) { 1532 Log.w(TAG, "Couldn't obtain cursor for modelId: " + doc.modelId); 1533 return false; 1534 } 1535 1536 final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE); 1537 final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS); 1538 return mTuner.canSelectType(docMimeType, docFlags); 1539 } 1540 } 1541 1542 private final class ModelUpdateListener implements Model.UpdateListener { 1543 @Override onModelUpdate(Model model)1544 public void onModelUpdate(Model model) { 1545 if (model.info != null || model.error != null) { 1546 mMessageBar.setInfo(model.info); 1547 mMessageBar.setError(model.error); 1548 mMessageBar.show(); 1549 } 1550 1551 mProgressBar.setVisibility(model.isLoading() ? View.VISIBLE : View.GONE); 1552 1553 if (model.isEmpty()) { 1554 if (mSearchMode) { 1555 showNoResults(getDisplayState().stack.root); 1556 } else { 1557 showEmptyDirectory(); 1558 } 1559 } else { 1560 showDirectory(); 1561 mAdapter.notifyDataSetChanged(); 1562 } 1563 1564 if (!model.isLoading()) { 1565 ((BaseActivity) getActivity()).notifyDirectoryLoaded( 1566 model.doc != null ? model.doc.derivedUri : null); 1567 } 1568 } 1569 1570 @Override onModelUpdateFailed(Exception e)1571 public void onModelUpdateFailed(Exception e) { 1572 showQueryError(); 1573 } 1574 } 1575 1576 private DragStartHelper.OnDragStartListener mOnDragStartListener = 1577 new DragStartHelper.OnDragStartListener() { 1578 @Override 1579 public boolean onDragStart(View v, DragStartHelper helper) { 1580 if (isSelected(getModelId(v))) { 1581 List<DocumentInfo> docs = getDraggableDocuments(v); 1582 if (docs.isEmpty()) { 1583 return false; 1584 } 1585 v.startDragAndDrop( 1586 mClipper.getClipDataForDocuments(docs), 1587 new DragShadowBuilder(getActivity(), mIconHelper, docs), 1588 getDisplayState().stack.peek(), 1589 View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_GLOBAL_URI_READ | 1590 View.DRAG_FLAG_GLOBAL_URI_WRITE 1591 ); 1592 return true; 1593 } 1594 1595 return false; 1596 } 1597 }; 1598 1599 private DragStartHelper mDragHelper = new DragStartHelper(null, mOnDragStartListener); 1600 1601 private View.OnLongClickListener onLongClickListener = new View.OnLongClickListener() { 1602 @Override 1603 public boolean onLongClick(View v) { 1604 return mDragHelper.onLongClick(v); 1605 } 1606 }; 1607 1608 // Previously we listened to events with one class, only to bounce them forward 1609 // to GestureDetector. We're still doing that here, but with a single class 1610 // that reduces overall complexity in our glue code. 1611 private static final class ListeningGestureDetector extends GestureDetector 1612 implements OnItemTouchListener { 1613 1614 private int mLastTool = -1; 1615 private DragStartHelper mDragHelper; 1616 ListeningGestureDetector( Context context, DragStartHelper dragHelper, GestureListener listener)1617 public ListeningGestureDetector( 1618 Context context, DragStartHelper dragHelper, GestureListener listener) { 1619 super(context, listener); 1620 mDragHelper = dragHelper; 1621 setOnDoubleTapListener(listener); 1622 } 1623 mouseSpawnedLastEvent()1624 boolean mouseSpawnedLastEvent() { 1625 return Events.isMouseType(mLastTool); 1626 } 1627 touchSpawnedLastEvent()1628 boolean touchSpawnedLastEvent() { 1629 return Events.isTouchType(mLastTool); 1630 } 1631 1632 @Override onInterceptTouchEvent(RecyclerView rv, MotionEvent e)1633 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { 1634 mLastTool = e.getToolType(0); 1635 1636 // Detect drag events. When a drag is detected, intercept the rest of the gesture. 1637 View itemView = rv.findChildViewUnder(e.getX(), e.getY()); 1638 if (itemView != null && mDragHelper.onTouch(itemView, e)) { 1639 return true; 1640 } 1641 // Forward unhandled events to the GestureDetector. 1642 onTouchEvent(e); 1643 1644 return false; 1645 } 1646 1647 @Override onTouchEvent(RecyclerView rv, MotionEvent e)1648 public void onTouchEvent(RecyclerView rv, MotionEvent e) { 1649 View itemView = rv.findChildViewUnder(e.getX(), e.getY()); 1650 mDragHelper.onTouch(itemView, e); 1651 // Note: even though this event is being handled as part of a drag gesture, continue 1652 // forwarding to the GestureDetector. The detector needs to see the entire cluster of 1653 // events in order to properly interpret gestures. 1654 onTouchEvent(e); 1655 } 1656 1657 @Override onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)1658 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} 1659 } 1660 1661 /** 1662 * The gesture listener for items in the list/grid view. Interprets gestures and sends the 1663 * events to the target DocumentHolder, whence they are routed to the appropriate listener. 1664 */ 1665 private class GestureListener extends GestureDetector.SimpleOnGestureListener { 1666 @Override onSingleTapUp(MotionEvent e)1667 public boolean onSingleTapUp(MotionEvent e) { 1668 // Single tap logic: 1669 // If the selection manager is active, it gets first whack at handling tap 1670 // events. Otherwise, tap events are routed to the target DocumentHolder. 1671 boolean handled = mSelectionManager.onSingleTapUp( 1672 new MotionInputEvent(e, mRecView)); 1673 1674 if (handled) { 1675 return handled; 1676 } 1677 1678 // Give the DocumentHolder a crack at the event. 1679 DocumentHolder holder = getTarget(e); 1680 if (holder != null) { 1681 handled = holder.onSingleTapUp(e); 1682 } 1683 1684 return handled; 1685 } 1686 1687 @Override onLongPress(MotionEvent e)1688 public void onLongPress(MotionEvent e) { 1689 // Long-press events get routed directly to the selection manager. They can be 1690 // changed to route through the DocumentHolder if necessary. 1691 mSelectionManager.onLongPress(new MotionInputEvent(e, mRecView)); 1692 } 1693 1694 @Override onDoubleTap(MotionEvent e)1695 public boolean onDoubleTap(MotionEvent e) { 1696 // Double-tap events are handled directly by the DirectoryFragment. They can be changed 1697 // to route through the DocumentHolder if necessary. 1698 return DirectoryFragment.this.onDoubleTap(e); 1699 } 1700 getTarget(MotionEvent e)1701 private @Nullable DocumentHolder getTarget(MotionEvent e) { 1702 View childView = mRecView.findChildViewUnder(e.getX(), e.getY()); 1703 if (childView != null) { 1704 return (DocumentHolder) mRecView.getChildViewHolder(childView); 1705 } else { 1706 return null; 1707 } 1708 } 1709 } 1710 showDirectory( FragmentManager fm, RootInfo root, DocumentInfo doc, int anim)1711 public static void showDirectory( 1712 FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) { 1713 create(fm, TYPE_NORMAL, root, doc, null, anim); 1714 } 1715 showRecentsOpen(FragmentManager fm, int anim)1716 public static void showRecentsOpen(FragmentManager fm, int anim) { 1717 create(fm, TYPE_RECENT_OPEN, null, null, null, anim); 1718 } 1719 reloadSearch(FragmentManager fm, RootInfo root, DocumentInfo doc, String query)1720 public static void reloadSearch(FragmentManager fm, RootInfo root, DocumentInfo doc, 1721 String query) { 1722 DirectoryFragment df = get(fm); 1723 1724 df.mQuery = query; 1725 df.mRoot = root; 1726 df.mDocument = doc; 1727 df.mSearchMode = query != null; 1728 df.getLoaderManager().restartLoader(LOADER_ID, null, df); 1729 } 1730 reload(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, String query)1731 public static void reload(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, 1732 String query) { 1733 DirectoryFragment df = get(fm); 1734 df.mType = type; 1735 df.mQuery = query; 1736 df.mRoot = root; 1737 df.mDocument = doc; 1738 df.mSearchMode = query != null; 1739 df.getLoaderManager().restartLoader(LOADER_ID, null, df); 1740 } 1741 create(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, String query, int anim)1742 public static void create(FragmentManager fm, int type, RootInfo root, DocumentInfo doc, 1743 String query, int anim) { 1744 final Bundle args = new Bundle(); 1745 args.putInt(Shared.EXTRA_TYPE, type); 1746 args.putParcelable(Shared.EXTRA_ROOT, root); 1747 args.putParcelable(Shared.EXTRA_DOC, doc); 1748 args.putString(Shared.EXTRA_QUERY, query); 1749 args.putParcelable(Shared.EXTRA_SELECTION, new Selection()); 1750 1751 final FragmentTransaction ft = fm.beginTransaction(); 1752 AnimationView.setupAnimations(ft, anim, args); 1753 1754 final DirectoryFragment fragment = new DirectoryFragment(); 1755 fragment.setArguments(args); 1756 1757 ft.replace(getFragmentId(), fragment); 1758 ft.commitAllowingStateLoss(); 1759 } 1760 buildStateKey(RootInfo root, DocumentInfo doc)1761 private static String buildStateKey(RootInfo root, DocumentInfo doc) { 1762 final StringBuilder builder = new StringBuilder(); 1763 builder.append(root != null ? root.authority : "null").append(';'); 1764 builder.append(root != null ? root.rootId : "null").append(';'); 1765 builder.append(doc != null ? doc.documentId : "null"); 1766 return builder.toString(); 1767 } 1768 get(FragmentManager fm)1769 public static @Nullable DirectoryFragment get(FragmentManager fm) { 1770 // TODO: deal with multiple directories shown at once 1771 Fragment fragment = fm.findFragmentById(getFragmentId()); 1772 return fragment instanceof DirectoryFragment 1773 ? (DirectoryFragment) fragment 1774 : null; 1775 } 1776 getFragmentId()1777 private static int getFragmentId() { 1778 return R.id.container_directory; 1779 } 1780 1781 @Override onCreateLoader(int id, Bundle args)1782 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { 1783 Context context = getActivity(); 1784 State state = getDisplayState(); 1785 1786 Uri contentsUri; 1787 switch (mType) { 1788 case TYPE_NORMAL: 1789 contentsUri = mSearchMode ? DocumentsContract.buildSearchDocumentsUri( 1790 mRoot.authority, mRoot.rootId, mQuery) 1791 : DocumentsContract.buildChildDocumentsUri( 1792 mDocument.authority, mDocument.documentId); 1793 if (mTuner.managedModeEnabled()) { 1794 contentsUri = DocumentsContract.setManageMode(contentsUri); 1795 } 1796 return new DirectoryLoader( 1797 context, mType, mRoot, mDocument, contentsUri, state.userSortOrder, 1798 mSearchMode); 1799 case TYPE_RECENT_OPEN: 1800 final RootsCache roots = DocumentsApplication.getRootsCache(context); 1801 return new RecentsLoader(context, roots, state); 1802 1803 default: 1804 throw new IllegalStateException("Unknown type " + mType); 1805 } 1806 } 1807 1808 @Override onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result)1809 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { 1810 if (!isAdded()) return; 1811 1812 if (mSearchMode) { 1813 Metrics.logUserAction(getContext(), Metrics.USER_ACTION_SEARCH); 1814 } 1815 1816 State state = getDisplayState(); 1817 1818 mAdapter.notifyDataSetChanged(); 1819 mModel.update(result); 1820 1821 state.derivedSortOrder = result.sortOrder; 1822 1823 updateLayout(state.derivedMode); 1824 1825 if (mSelection != null) { 1826 mSelectionManager.setItemsSelected(mSelection.toList(), true); 1827 mSelection.clear(); 1828 } 1829 1830 // Restore any previous instance state 1831 final SparseArray<Parcelable> container = state.dirState.remove(mStateKey); 1832 if (container != null && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, false)) { 1833 getView().restoreHierarchyState(container); 1834 } else if (mLastSortOrder != state.derivedSortOrder) { 1835 // The derived sort order takes the user sort order into account, but applies 1836 // directory-specific defaults when the user doesn't explicitly set the sort 1837 // order. Scroll to the top if the sort order actually changed. 1838 mRecView.smoothScrollToPosition(0); 1839 } 1840 1841 mLastSortOrder = state.derivedSortOrder; 1842 1843 mTuner.onModelLoaded(mModel, mType, mSearchMode); 1844 1845 } 1846 1847 @Override onLoaderReset(Loader<DirectoryResult> loader)1848 public void onLoaderReset(Loader<DirectoryResult> loader) { 1849 mModel.update(null); 1850 } 1851 } 1852