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