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