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