1 /* 2 * Copyright (C) 2016 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; 18 19 import static com.android.documentsui.base.DocumentInfo.getCursorInt; 20 import static com.android.documentsui.base.DocumentInfo.getCursorString; 21 import static com.android.documentsui.base.SharedMinimal.DEBUG; 22 import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled; 23 import static com.android.documentsui.util.FlagUtils.isUseSearchV2FlagEnabled; 24 import static com.android.documentsui.util.FlagUtils.isZipNgFlagEnabled; 25 26 import android.app.PendingIntent; 27 import android.content.ActivityNotFoundException; 28 import android.content.ComponentName; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.content.IntentSender; 32 import android.content.pm.PackageManager; 33 import android.content.pm.ResolveInfo; 34 import android.database.Cursor; 35 import android.net.Uri; 36 import android.os.Bundle; 37 import android.os.Parcelable; 38 import android.provider.DocumentsContract; 39 import android.util.Log; 40 import android.util.Pair; 41 import android.view.DragEvent; 42 43 import androidx.annotation.NonNull; 44 import androidx.annotation.VisibleForTesting; 45 import androidx.fragment.app.FragmentActivity; 46 import androidx.loader.app.LoaderManager.LoaderCallbacks; 47 import androidx.loader.content.Loader; 48 import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; 49 import androidx.recyclerview.selection.MutableSelection; 50 import androidx.recyclerview.selection.SelectionTracker; 51 52 import com.android.documentsui.AbstractActionHandler.CommonAddons; 53 import com.android.documentsui.LoadDocStackTask.LoadDocStackCallback; 54 import com.android.documentsui.base.BooleanConsumer; 55 import com.android.documentsui.base.DocumentInfo; 56 import com.android.documentsui.base.DocumentStack; 57 import com.android.documentsui.base.Lookup; 58 import com.android.documentsui.base.MimeTypes; 59 import com.android.documentsui.base.Providers; 60 import com.android.documentsui.base.RootInfo; 61 import com.android.documentsui.base.Shared; 62 import com.android.documentsui.base.State; 63 import com.android.documentsui.base.UserId; 64 import com.android.documentsui.dirlist.AnimationView; 65 import com.android.documentsui.dirlist.AnimationView.AnimationType; 66 import com.android.documentsui.dirlist.FocusHandler; 67 import com.android.documentsui.files.LauncherActivity; 68 import com.android.documentsui.files.QuickViewIntentBuilder; 69 import com.android.documentsui.loaders.FolderLoader; 70 import com.android.documentsui.loaders.QueryOptions; 71 import com.android.documentsui.loaders.SearchLoader; 72 import com.android.documentsui.queries.SearchViewManager; 73 import com.android.documentsui.roots.GetRootDocumentTask; 74 import com.android.documentsui.roots.LoadFirstRootTask; 75 import com.android.documentsui.roots.LoadRootTask; 76 import com.android.documentsui.roots.ProvidersAccess; 77 import com.android.documentsui.sidebar.EjectRootTask; 78 import com.android.documentsui.sorting.SortListFragment; 79 import com.android.documentsui.ui.DialogController; 80 import com.android.documentsui.ui.Snackbars; 81 82 import java.time.Duration; 83 import java.util.ArrayList; 84 import java.util.Collection; 85 import java.util.List; 86 import java.util.Objects; 87 import java.util.concurrent.Executor; 88 import java.util.concurrent.ExecutorService; 89 import java.util.concurrent.Executors; 90 import java.util.concurrent.Semaphore; 91 import java.util.function.Consumer; 92 93 import javax.annotation.Nullable; 94 95 /** 96 * Provides support for specializing the actions (openDocument etc.) to the host activity. 97 */ 98 public abstract class AbstractActionHandler<T extends FragmentActivity & CommonAddons> 99 implements ActionHandler { 100 101 @VisibleForTesting 102 public static final int CODE_AUTHENTICATION = 43; 103 104 @VisibleForTesting 105 static final int LOADER_ID = 42; 106 107 private static final String TAG = "AbstractActionHandler"; 108 private static final int REFRESH_SPINNER_TIMEOUT = 500; 109 private final Semaphore mLoaderSemaphore = new Semaphore(1); 110 111 protected final T mActivity; 112 protected final State mState; 113 protected final ProvidersAccess mProviders; 114 protected final DocumentsAccess mDocs; 115 protected final FocusHandler mFocusHandler; 116 protected final SelectionTracker<String> mSelectionMgr; 117 protected final SearchViewManager mSearchMgr; 118 protected final Lookup<String, Executor> mExecutors; 119 protected final DialogController mDialogs; 120 protected final Model mModel; 121 protected final Injector<?> mInjector; 122 123 private final LoaderBindings mBindings; 124 125 private Runnable mDisplayStateChangedListener; 126 127 private ContentLock mContentLock; 128 129 @Override registerDisplayStateChangedListener(Runnable l)130 public void registerDisplayStateChangedListener(Runnable l) { 131 mDisplayStateChangedListener = l; 132 } 133 134 @Override unregisterDisplayStateChangedListener(Runnable l)135 public void unregisterDisplayStateChangedListener(Runnable l) { 136 if (mDisplayStateChangedListener == l) { 137 mDisplayStateChangedListener = null; 138 } 139 } 140 AbstractActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, Injector<?> injector)141 public AbstractActionHandler( 142 T activity, 143 State state, 144 ProvidersAccess providers, 145 DocumentsAccess docs, 146 SearchViewManager searchMgr, 147 Lookup<String, Executor> executors, 148 Injector<?> injector) { 149 150 assert (activity != null); 151 assert (state != null); 152 assert (providers != null); 153 assert (searchMgr != null); 154 assert (docs != null); 155 assert (injector != null); 156 157 mActivity = activity; 158 mState = state; 159 mProviders = providers; 160 mDocs = docs; 161 mFocusHandler = injector.focusManager; 162 mSelectionMgr = injector.selectionMgr; 163 mSearchMgr = searchMgr; 164 mExecutors = executors; 165 mDialogs = injector.dialogs; 166 mModel = injector.getModel(); 167 mInjector = injector; 168 169 mBindings = new LoaderBindings(); 170 } 171 172 @Override ejectRoot(RootInfo root, BooleanConsumer listener)173 public void ejectRoot(RootInfo root, BooleanConsumer listener) { 174 new EjectRootTask( 175 mActivity.getContentResolver(), 176 root.authority, 177 root.rootId, 178 listener).executeOnExecutor(ProviderExecutor.forAuthority(root.authority)); 179 } 180 181 @Override startAuthentication(PendingIntent intent)182 public void startAuthentication(PendingIntent intent) { 183 try { 184 mActivity.startIntentSenderForResult(intent.getIntentSender(), CODE_AUTHENTICATION, 185 null, 0, 0, 0); 186 } catch (IntentSender.SendIntentException cancelled) { 187 Log.d(TAG, "Authentication Pending Intent either canceled or ignored."); 188 } 189 } 190 191 @Override requestQuietModeDisabled(RootInfo info, UserId userId)192 public void requestQuietModeDisabled(RootInfo info, UserId userId) { 193 new RequestQuietModeDisabledTask(mActivity, userId).execute(); 194 } 195 196 @Override onActivityResult(int requestCode, int resultCode, Intent data)197 public void onActivityResult(int requestCode, int resultCode, Intent data) { 198 switch (requestCode) { 199 case CODE_AUTHENTICATION: 200 onAuthenticationResult(resultCode); 201 break; 202 } 203 } 204 onAuthenticationResult(int resultCode)205 private void onAuthenticationResult(int resultCode) { 206 if (resultCode == FragmentActivity.RESULT_OK) { 207 Log.v(TAG, "Authentication was successful. Refreshing directory now."); 208 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 209 } 210 } 211 212 @Override getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback)213 public void getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback) { 214 GetRootDocumentTask task = new GetRootDocumentTask( 215 root, 216 mActivity, 217 timeout, 218 mDocs, 219 callback); 220 221 task.executeOnExecutor(mExecutors.lookup(root.authority)); 222 } 223 224 @Override refreshDocument(DocumentInfo doc, BooleanConsumer callback)225 public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) { 226 RefreshTask task = new RefreshTask( 227 mInjector.features, 228 mState, 229 doc, 230 REFRESH_SPINNER_TIMEOUT, 231 mActivity.getApplicationContext(), 232 mActivity::isDestroyed, 233 callback); 234 task.executeOnExecutor(mExecutors.lookup(doc == null ? null : doc.authority)); 235 } 236 237 @Override openSelectedInNewWindow()238 public void openSelectedInNewWindow() { 239 throw new UnsupportedOperationException("Can't open in new window."); 240 } 241 242 @Override openInNewWindow(DocumentStack path)243 public void openInNewWindow(DocumentStack path) { 244 Metrics.logUserAction(MetricConsts.USER_ACTION_NEW_WINDOW); 245 246 Intent intent = LauncherActivity.createLaunchIntent(mActivity); 247 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) path); 248 249 // Multi-window necessitates we pick how we are launched. 250 // By default we'd be launched in-place above the existing app. 251 // By setting launch-to-side ActivityManager will open us to side. 252 if (mActivity.isInMultiWindowMode()) { 253 intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); 254 } 255 256 mActivity.startActivity(intent); 257 } 258 259 @Override openItem(ItemDetails<String> doc, @ViewType int type, @ViewType int fallback)260 public boolean openItem(ItemDetails<String> doc, @ViewType int type, @ViewType int fallback) { 261 throw new UnsupportedOperationException("Can't open document."); 262 } 263 264 @Override openDocumentViewOnly(DocumentInfo doc)265 public void openDocumentViewOnly(DocumentInfo doc) { 266 throw new UnsupportedOperationException("Open doc not supported!"); 267 } 268 269 @Override showPreview(DocumentInfo doc)270 public void showPreview(DocumentInfo doc) { 271 throw new UnsupportedOperationException("Can't open properties."); 272 } 273 274 @Override springOpenDirectory(DocumentInfo doc)275 public void springOpenDirectory(DocumentInfo doc) { 276 throw new UnsupportedOperationException("Can't spring open directories."); 277 } 278 279 @Override openSettings(RootInfo root)280 public void openSettings(RootInfo root) { 281 throw new UnsupportedOperationException("Can't open settings."); 282 } 283 284 @Override openRoot(ResolveInfo app, UserId userId)285 public void openRoot(ResolveInfo app, UserId userId) { 286 throw new UnsupportedOperationException("Can't open an app."); 287 } 288 289 @Override showAppDetails(ResolveInfo info, UserId userId)290 public void showAppDetails(ResolveInfo info, UserId userId) { 291 throw new UnsupportedOperationException("Can't show app details."); 292 } 293 294 @Override dropOn(DragEvent event, RootInfo root)295 public boolean dropOn(DragEvent event, RootInfo root) { 296 throw new UnsupportedOperationException("Can't open an app."); 297 } 298 299 @Override pasteIntoFolder(RootInfo root)300 public void pasteIntoFolder(RootInfo root) { 301 throw new UnsupportedOperationException("Can't paste into folder."); 302 } 303 304 @Override viewInOwner()305 public void viewInOwner() { 306 throw new UnsupportedOperationException("Can't view in application."); 307 } 308 309 @Override selectAllFiles()310 public void selectAllFiles() { 311 Metrics.logUserAction(MetricConsts.USER_ACTION_SELECT_ALL); 312 Model model = mInjector.getModel(); 313 314 // Exclude disabled files 315 List<String> enabled = new ArrayList<>(); 316 for (String id : model.getModelIds()) { 317 Cursor cursor = model.getItem(id); 318 if (cursor == null) { 319 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id); 320 continue; 321 } 322 String docMimeType = getCursorString( 323 cursor, DocumentsContract.Document.COLUMN_MIME_TYPE); 324 int docFlags = getCursorInt(cursor, DocumentsContract.Document.COLUMN_FLAGS); 325 if (mInjector.config.isDocumentEnabled(docMimeType, docFlags, mState)) { 326 enabled.add(id); 327 } 328 } 329 330 // Only select things currently visible in the adapter. 331 boolean changed = mSelectionMgr.setItemsSelected(enabled, true); 332 if (changed) { 333 mDisplayStateChangedListener.run(); 334 } 335 } 336 337 @Override deselectAllFiles()338 public void deselectAllFiles() { 339 mSelectionMgr.clearSelection(); 340 } 341 342 @Override showCreateDirectoryDialog()343 public void showCreateDirectoryDialog() { 344 Metrics.logUserAction(MetricConsts.USER_ACTION_CREATE_DIR); 345 346 CreateDirectoryFragment.show(mActivity.getSupportFragmentManager()); 347 } 348 349 @Override showSortDialog()350 public void showSortDialog() { 351 SortListFragment.show(mActivity.getSupportFragmentManager(), mState.sortModel); 352 } 353 354 @Override 355 @Nullable renameDocument(String name, DocumentInfo document)356 public DocumentInfo renameDocument(String name, DocumentInfo document) { 357 throw new UnsupportedOperationException("Can't rename documents."); 358 } 359 360 @Override showChooserForDoc(DocumentInfo doc)361 public void showChooserForDoc(DocumentInfo doc) { 362 throw new UnsupportedOperationException("Show chooser for doc not supported!"); 363 } 364 365 @Override openRootDocument(@ullable DocumentInfo rootDoc)366 public void openRootDocument(@Nullable DocumentInfo rootDoc) { 367 if (rootDoc == null) { 368 // There are 2 cases where rootDoc is null -- 1) loading recents; 2) failed to load root 369 // document. Either case we should call refreshCurrentRootAndDirectory() to let 370 // DirectoryFragment update UI. 371 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 372 } else { 373 openContainerDocument(rootDoc); 374 } 375 } 376 377 @Override openContainerDocument(DocumentInfo doc)378 public void openContainerDocument(DocumentInfo doc) { 379 assert (doc.isContainer()); 380 381 if (mSearchMgr.isSearching()) { 382 loadDocument( 383 doc.derivedUri, 384 doc.userId, 385 (@Nullable DocumentStack stack) -> openFolderInSearchResult(stack, doc)); 386 } else { 387 openChildContainer(doc); 388 } 389 } 390 391 // TODO: Make this private and make tests call interface method instead. 392 393 /** 394 * Behavior when a document is opened. 395 */ 396 @VisibleForTesting onDocumentOpened(DocumentInfo doc, @ViewType int type, @ViewType int fallback, boolean fromPicker)397 public void onDocumentOpened(DocumentInfo doc, @ViewType int type, @ViewType int fallback, 398 boolean fromPicker) { 399 // In picker mode, don't access archive container to avoid pick file in archive files. 400 if (doc.isContainer() && !fromPicker) { 401 openContainerDocument(doc); 402 return; 403 } 404 405 if (manageDocument(doc)) { 406 return; 407 } 408 409 // For APKs, even if the type is preview, we send an ACTION_VIEW intent to allow 410 // PackageManager to install it. This allows users to install APKs from any root. 411 // The Downloads special case is handled above in #manageDocument. 412 if (MimeTypes.isApkType(doc.mimeType)) { 413 viewDocument(doc); 414 return; 415 } 416 417 switch (type) { 418 case VIEW_TYPE_REGULAR: 419 if (viewDocument(doc)) { 420 return; 421 } 422 break; 423 424 case VIEW_TYPE_PREVIEW: 425 if (previewDocument(doc, fromPicker)) { 426 return; 427 } 428 break; 429 430 default: 431 throw new IllegalArgumentException("Illegal view type."); 432 } 433 434 switch (fallback) { 435 case VIEW_TYPE_REGULAR: 436 if (viewDocument(doc)) { 437 return; 438 } 439 break; 440 441 case VIEW_TYPE_PREVIEW: 442 if (previewDocument(doc, fromPicker)) { 443 return; 444 } 445 break; 446 447 case VIEW_TYPE_NONE: 448 break; 449 450 default: 451 throw new IllegalArgumentException("Illegal fallback view type."); 452 } 453 454 // Failed to view including fallback, and it's in an archive. 455 if (type != VIEW_TYPE_NONE && fallback != VIEW_TYPE_NONE && doc.isInArchive()) { 456 mDialogs.showViewInArchivesUnsupported(); 457 } 458 } 459 viewDocument(DocumentInfo doc)460 private boolean viewDocument(DocumentInfo doc) { 461 if (doc.isPartial()) { 462 Log.w(TAG, "Cannot view partial file"); 463 return false; 464 } 465 466 if (!isZipNgFlagEnabled() && doc.isInArchive()) { 467 Log.w(TAG, "Cannot view file in archive"); 468 return false; 469 } 470 471 if (doc.isDirectory()) { 472 Log.w(TAG, "Cannot view directory"); 473 return true; 474 } 475 476 Intent intent = buildViewIntent(doc); 477 if (DEBUG && intent.getClipData() != null) { 478 Log.d(TAG, "Starting intent w/ clip data: " + intent.getClipData()); 479 } 480 481 try { 482 doc.userId.startActivityAsUser(mActivity, intent); 483 return true; 484 } catch (ActivityNotFoundException e) { 485 mDialogs.showNoApplicationFound(); 486 } 487 return false; 488 } 489 previewDocument(DocumentInfo doc, boolean fromPicker)490 private boolean previewDocument(DocumentInfo doc, boolean fromPicker) { 491 if (doc.isPartial()) { 492 Log.w(TAG, "Can't view partial file."); 493 return false; 494 } 495 496 Intent intent = new QuickViewIntentBuilder( 497 mActivity, 498 mActivity.getResources(), 499 doc, 500 mModel, 501 fromPicker).build(); 502 503 if (intent != null) { 504 // TODO: un-work around issue b/24963914. Should be fixed soon. 505 try { 506 doc.userId.startActivityAsUser(mActivity, intent); 507 return true; 508 } catch (SecurityException e) { 509 // Carry on to regular view mode. 510 Log.e(TAG, "Caught security error: " + e.getLocalizedMessage()); 511 } 512 } 513 514 return false; 515 } 516 517 manageDocument(DocumentInfo doc)518 protected boolean manageDocument(DocumentInfo doc) { 519 if (isManagedDownload(doc)) { 520 // First try managing the document; we expect manager to filter 521 // based on authority, so we don't grant. 522 Intent manage = new Intent(DocumentsContract.ACTION_MANAGE_DOCUMENT); 523 manage.setData(doc.getDocumentUri()); 524 try { 525 doc.userId.startActivityAsUser(mActivity, manage); 526 return true; 527 } catch (ActivityNotFoundException ex) { 528 // Fall back to regular handling. 529 } 530 } 531 532 return false; 533 } 534 isManagedDownload(DocumentInfo doc)535 private boolean isManagedDownload(DocumentInfo doc) { 536 // Anything on downloads goes through the back through downloads manager 537 // (that's the MANAGE_DOCUMENT bit). 538 // This is done for two reasons: 539 // 1) The file in question might be a failed/queued or otherwise have some 540 // specialized download handling. 541 // 2) For APKs, the download manager will add on some important security stuff 542 // like origin URL. 543 // 3) For partial files, the download manager will offer to restart/retry downloads. 544 545 // All other files not on downloads, event APKs, would get no benefit from this 546 // treatment, thusly the "isDownloads" check. 547 548 // Launch MANAGE_DOCUMENTS only for the root level files, so it's not called for 549 // files in archives or in child folders. Also, if the activity is already browsing 550 // a ZIP from downloads, then skip MANAGE_DOCUMENTS. 551 if (Intent.ACTION_VIEW.equals(mActivity.getIntent().getAction()) 552 && mState.stack.size() > 1) { 553 // viewing the contents of an archive. 554 return false; 555 } 556 557 // management is only supported in Downloads root or downloaded files show in Recent root. 558 if (Providers.AUTHORITY_DOWNLOADS.equals(doc.authority)) { 559 // only on APKs or partial files. 560 return MimeTypes.isApkType(doc.mimeType) || doc.isPartial(); 561 } 562 563 return false; 564 } 565 buildViewIntent(DocumentInfo doc)566 protected Intent buildViewIntent(DocumentInfo doc) { 567 Intent intent = new Intent(Intent.ACTION_VIEW); 568 intent.setDataAndType(doc.getDocumentUri(), doc.mimeType); 569 570 // Downloads has traditionally added the WRITE permission 571 // in the TrampolineActivity. Since this behavior is long 572 // established, we set the same permission for non-managed files 573 // This ensures consistent behavior between the Downloads root 574 // and other roots. 575 int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_SINGLE_TOP; 576 if (doc.isWriteSupported()) { 577 flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; 578 } 579 // On desktop users expect files to open in a new window. 580 if (isDesktopFileHandlingFlagEnabled()) { 581 // The combination of NEW_DOCUMENT and MULTIPLE_TASK allows multiple instances of the 582 // same activity to open in separate windows. 583 flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; 584 // If the activity has documentLaunchMode="never", NEW_TASK forces the activity to still 585 // open in a new window. 586 flags |= Intent.FLAG_ACTIVITY_NEW_TASK; 587 } 588 intent.setFlags(flags); 589 590 return intent; 591 } 592 593 @Override previewItem(ItemDetails<String> doc)594 public boolean previewItem(ItemDetails<String> doc) { 595 throw new UnsupportedOperationException("Can't handle preview."); 596 } 597 openFolderInSearchResult(@ullable DocumentStack stack, DocumentInfo doc)598 private void openFolderInSearchResult(@Nullable DocumentStack stack, DocumentInfo doc) { 599 if (stack == null) { 600 mState.stack.popToRootDocument(); 601 602 // Update navigator to give horizontal breadcrumb a chance to update documents. It 603 // doesn't update its content if the size of document stack doesn't change. 604 // TODO: update breadcrumb to take range update. 605 mActivity.updateNavigator(); 606 607 mState.stack.push(doc); 608 } else { 609 if (!Objects.equals(mState.stack.getRoot(), stack.getRoot())) { 610 // It is now possible when opening cross-profile folder. 611 Log.w(TAG, "Provider returns " + stack.getRoot() + " rather than expected " 612 + mState.stack.getRoot()); 613 } 614 615 final DocumentInfo top = stack.peek(); 616 if (top.isArchive()) { 617 // Swap the zip file in original provider and the one provided by ArchiveProvider. 618 stack.pop(); 619 stack.push(mDocs.getArchiveDocument(top.derivedUri, top.userId)); 620 } 621 622 mState.stack.reset(); 623 // Update navigator to give horizontal breadcrumb a chance to update documents. It 624 // doesn't update its content if the size of document stack doesn't change. 625 // TODO: update breadcrumb to take range update. 626 mActivity.updateNavigator(); 627 628 mState.stack.reset(stack); 629 } 630 631 // Show an opening animation only if pressing "back" would get us back to the 632 // previous directory. Especially after opening a root document, pressing 633 // back, wouldn't go to the previous root, but close the activity. 634 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 635 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 636 mActivity.refreshCurrentRootAndDirectory(anim); 637 } 638 openChildContainer(DocumentInfo doc)639 private void openChildContainer(DocumentInfo doc) { 640 DocumentInfo currentDoc = null; 641 642 if (doc.isDirectory()) { 643 // Regular directory. 644 currentDoc = doc; 645 } else if (doc.isArchive()) { 646 // Archive. 647 currentDoc = mDocs.getArchiveDocument(doc.derivedUri, doc.userId); 648 } 649 650 assert (currentDoc != null); 651 if (currentDoc.equals(mState.stack.peek())) { 652 Log.w(TAG, "This DocumentInfo is already in current DocumentsStack"); 653 return; 654 } 655 656 mActivity.notifyDirectoryNavigated(currentDoc.derivedUri); 657 658 mState.stack.push(currentDoc); 659 // Show an opening animation only if pressing "back" would get us back to the 660 // previous directory. Especially after opening a root document, pressing 661 // back, wouldn't go to the previous root, but close the activity. 662 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 663 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 664 mActivity.refreshCurrentRootAndDirectory(anim); 665 } 666 667 @Override setDebugMode(boolean enabled)668 public void setDebugMode(boolean enabled) { 669 if (!mInjector.features.isDebugSupportEnabled()) { 670 return; 671 } 672 673 mState.debugMode = enabled; 674 mInjector.features.forceFeature(R.bool.feature_command_interceptor, enabled); 675 mInjector.features.forceFeature(R.bool.feature_inspector, enabled); 676 mActivity.invalidateOptionsMenu(); 677 678 if (enabled) { 679 showDebugMessage(); 680 } else { 681 mActivity.getWindow().setStatusBarColor( 682 mActivity.getResources().getColor(R.color.app_background_color)); 683 } 684 } 685 686 @Override showDebugMessage()687 public void showDebugMessage() { 688 assert (mInjector.features.isDebugSupportEnabled()); 689 690 int[] colors = mInjector.debugHelper.getNextColors(); 691 Pair<String, Integer> messagePair = mInjector.debugHelper.getNextMessage(); 692 693 Snackbars.showCustomTextWithImage(mActivity, messagePair.first, messagePair.second); 694 695 mActivity.getWindow().setStatusBarColor(colors[1]); 696 } 697 698 @Override switchLauncherIcon()699 public void switchLauncherIcon() { 700 PackageManager pm = mActivity.getPackageManager(); 701 if (pm != null) { 702 final boolean enalbled = Shared.isLauncherEnabled(mActivity); 703 ComponentName component = new ComponentName( 704 mActivity.getPackageName(), Shared.LAUNCHER_TARGET_CLASS); 705 pm.setComponentEnabledSetting(component, enalbled 706 ? PackageManager.COMPONENT_ENABLED_STATE_DISABLED 707 : PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 708 PackageManager.DONT_KILL_APP); 709 } 710 } 711 712 @Override cutToClipboard()713 public void cutToClipboard() { 714 throw new UnsupportedOperationException("Cut not supported!"); 715 } 716 717 @Override copyToClipboard()718 public void copyToClipboard() { 719 throw new UnsupportedOperationException("Copy not supported!"); 720 } 721 722 @Override showDeleteDialog()723 public void showDeleteDialog() { 724 throw new UnsupportedOperationException("Delete not supported!"); 725 } 726 727 @Override deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent)728 public void deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent) { 729 throw new UnsupportedOperationException("Delete not supported!"); 730 } 731 732 @Override shareSelectedDocuments()733 public void shareSelectedDocuments() { 734 throw new UnsupportedOperationException("Share not supported!"); 735 } 736 loadDocument(Uri uri, UserId userId, LoadDocStackCallback callback)737 protected final void loadDocument(Uri uri, UserId userId, LoadDocStackCallback callback) { 738 new LoadDocStackTask( 739 mActivity, 740 mProviders, 741 mDocs, 742 userId, 743 callback 744 ).executeOnExecutor(mExecutors.lookup(uri.getAuthority()), uri); 745 } 746 747 @Override loadRoot(Uri uri, UserId userId)748 public final void loadRoot(Uri uri, UserId userId) { 749 new LoadRootTask<>(mActivity, mProviders, uri, userId, this::onRootLoaded) 750 .executeOnExecutor(mExecutors.lookup(uri.getAuthority())); 751 } 752 753 @Override loadCrossProfileRoot(RootInfo info, UserId selectedUser)754 public final void loadCrossProfileRoot(RootInfo info, UserId selectedUser) { 755 if (info.isRecents()) { 756 openRoot(mProviders.getRecentsRoot(selectedUser)); 757 return; 758 } 759 new LoadRootTask<>(mActivity, mProviders, info.getUri(), selectedUser, 760 new LoadCrossProfileRootCallback(info, selectedUser)) 761 .executeOnExecutor(mExecutors.lookup(info.getUri().getAuthority())); 762 } 763 764 private class LoadCrossProfileRootCallback implements LoadRootTask.LoadRootCallback { 765 private final RootInfo mOriginalRoot; 766 private final UserId mSelectedUserId; 767 LoadCrossProfileRootCallback(RootInfo rootInfo, UserId selectedUser)768 LoadCrossProfileRootCallback(RootInfo rootInfo, UserId selectedUser) { 769 mOriginalRoot = rootInfo; 770 mSelectedUserId = selectedUser; 771 } 772 773 @Override onRootLoaded(@ullable RootInfo root)774 public void onRootLoaded(@Nullable RootInfo root) { 775 if (root == null) { 776 // There is no such root in the other profile. Maybe the provider is missing on 777 // the other profile. Create a placeholder root and open it to show error message. 778 root = RootInfo.copyRootInfo(mOriginalRoot); 779 root.userId = mSelectedUserId; 780 } 781 openRoot(root); 782 } 783 } 784 785 @Override loadFirstRoot(Uri uri)786 public final void loadFirstRoot(Uri uri) { 787 new LoadFirstRootTask<>(mActivity, mProviders, uri, this::onRootLoaded) 788 .executeOnExecutor(mExecutors.lookup(uri.getAuthority())); 789 } 790 791 @Override loadDocumentsForCurrentStack()792 public void loadDocumentsForCurrentStack() { 793 // mState.stack may be empty when we cannot load the root document. 794 // However, we still want to restart loader because we may need to perform search in a 795 // cross-profile scenario. 796 // For RecentsLoader and GlobalSearchLoader, they do not require rootDoc so it is no-op. 797 // For DirectoryLoader, the loader needs to handle the case when stack.peek() returns null. 798 799 // Only allow restartLoader when the previous loader is finished or reset. Allowing 800 // multiple consecutive calls to restartLoader() / onCreateLoader() will probably create 801 // multiple active loaders, because restartLoader() does not interrupt previous loaders' 802 // loading, therefore may block the UI thread and cause ANR. 803 if (mLoaderSemaphore.tryAcquire()) { 804 mActivity.getSupportLoaderManager().restartLoader(LOADER_ID, null, mBindings); 805 } 806 } 807 launchToDocument(Uri uri)808 protected final boolean launchToDocument(Uri uri) { 809 if (DEBUG) { 810 Log.d(TAG, "launchToDocument() uri=" + uri); 811 } 812 813 // We don't support launching to a document in an archive. 814 if (Providers.isArchiveUri(uri)) { 815 return false; 816 } 817 818 loadDocument(uri, UserId.DEFAULT_USER, this::onStackToLaunchToLoaded); 819 return true; 820 } 821 822 /** 823 * Invoked <b>only</b> once, when the initial stack (that is the stack we are going to 824 * "launch to") is loaded. 825 * 826 * @see #launchToDocument(Uri) 827 */ onStackToLaunchToLoaded(@ullable DocumentStack stack)828 private void onStackToLaunchToLoaded(@Nullable DocumentStack stack) { 829 if (DEBUG) { 830 Log.d(TAG, "onLaunchStackLoaded() stack=" + stack); 831 } 832 833 if (stack == null) { 834 Log.w(TAG, "Failed to launch into the given uri. Launch to default location."); 835 launchToDefaultLocation(); 836 837 Metrics.logLaunchAtLocation(mState, null); 838 return; 839 } 840 841 // Make sure the document at the top of the stack is a directory (if it isn't - just pop 842 // one off). 843 if (!stack.peek().isDirectory()) { 844 stack.pop(); 845 } 846 847 mState.stack.reset(stack); 848 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 849 850 Metrics.logLaunchAtLocation(mState, stack.getRoot().getUri()); 851 } 852 onRootLoaded(@ullable RootInfo root)853 private void onRootLoaded(@Nullable RootInfo root) { 854 boolean invalidRootForAction = 855 (root != null 856 && !root.supportsChildren() 857 && mState.action == State.ACTION_OPEN_TREE); 858 859 if (invalidRootForAction) { 860 loadDeviceRoot(); 861 } else if (root != null) { 862 mActivity.onRootPicked(root); 863 } else { 864 launchToDefaultLocation(); 865 } 866 } 867 launchToDefaultLocation()868 protected abstract void launchToDefaultLocation(); 869 restoreRootAndDirectory()870 protected void restoreRootAndDirectory() { 871 if (!mState.stack.getRoot().isRecents() && mState.stack.isEmpty()) { 872 mActivity.onRootPicked(mState.stack.getRoot()); 873 } else { 874 mActivity.restoreRootAndDirectory(); 875 } 876 } 877 loadDeviceRoot()878 protected final void loadDeviceRoot() { 879 loadRoot(DocumentsContract.buildRootUri(Providers.AUTHORITY_STORAGE, 880 Providers.ROOT_ID_DEVICE), UserId.DEFAULT_USER); 881 } 882 loadHomeDir()883 protected final void loadHomeDir() { 884 loadRoot(Shared.getDefaultRootUri(mActivity), UserId.DEFAULT_USER); 885 } 886 loadRecent()887 protected final void loadRecent() { 888 mState.stack.changeRoot(mProviders.getRecentsRoot(UserId.DEFAULT_USER)); 889 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 890 } 891 getStableSelection()892 protected MutableSelection<String> getStableSelection() { 893 MutableSelection<String> selection = new MutableSelection<>(); 894 mSelectionMgr.copySelection(selection); 895 return selection; 896 } 897 898 @Override reset(ContentLock reloadLock)899 public ActionHandler reset(ContentLock reloadLock) { 900 mContentLock = reloadLock; 901 mActivity.getLoaderManager().destroyLoader(LOADER_ID); 902 return this; 903 } 904 905 private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> { 906 907 private ExecutorService mExecutorService = null; 908 private static final long MAX_SEARCH_TIME_MS = 3000; 909 private static final int MAX_RESULTS = 500; 910 911 @NonNull 912 @Override onCreateLoader(int id, Bundle args)913 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { 914 // If document stack is not initialized, i.e. if the root is null, create "Recents" root 915 // with the selected user. 916 if (!mState.stack.isInitialized()) { 917 mState.stack.changeRoot(mActivity.getCurrentRoot()); 918 } 919 920 if (isUseSearchV2FlagEnabled()) { 921 return onCreateLoaderV2(id, args); 922 } 923 return onCreateLoaderV1(id, args); 924 } 925 onCreateLoaderV1(int id, Bundle args)926 private Loader<DirectoryResult> onCreateLoaderV1(int id, Bundle args) { 927 Context context = mActivity; 928 929 if (mState.stack.isRecents()) { 930 final LockingContentObserver observer = new LockingContentObserver( 931 mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack); 932 MultiRootDocumentsLoader loader; 933 934 if (mSearchMgr.isSearching()) { 935 if (DEBUG) { 936 Log.d(TAG, "Creating new GlobalSearchLoader."); 937 } 938 loader = new GlobalSearchLoader( 939 context, 940 mProviders, 941 mState, 942 mExecutors, 943 mInjector.fileTypeLookup, 944 mSearchMgr.buildQueryArgs(), 945 mState.stack.getRoot().userId); 946 } else { 947 if (DEBUG) { 948 Log.d(TAG, "Creating new loader recents."); 949 } 950 loader = new RecentsLoader( 951 context, 952 mProviders, 953 mState, 954 mExecutors, 955 mInjector.fileTypeLookup, 956 mState.stack.getRoot().userId); 957 } 958 loader.setObserver(observer); 959 return loader; 960 } else { 961 // There maybe no root docInfo 962 DocumentInfo rootDoc = mState.stack.peek(); 963 964 String authority = rootDoc == null 965 ? mState.stack.getRoot().authority 966 : rootDoc.authority; 967 String documentId = rootDoc == null 968 ? mState.stack.getRoot().documentId 969 : rootDoc.documentId; 970 971 Uri contentsUri = mSearchMgr.isSearching() 972 ? DocumentsContract.buildSearchDocumentsUri( 973 mState.stack.getRoot().authority, 974 mState.stack.getRoot().rootId, 975 mSearchMgr.getCurrentSearch()) 976 : DocumentsContract.buildChildDocumentsUri( 977 authority, 978 documentId); 979 980 final Bundle queryArgs = mSearchMgr.isSearching() 981 ? mSearchMgr.buildQueryArgs() 982 : null; 983 984 if (mInjector.config.managedModeEnabled(mState.stack)) { 985 contentsUri = DocumentsContract.setManageMode(contentsUri); 986 } 987 988 if (DEBUG) { 989 Log.d(TAG, 990 "Creating new directory loader for: " 991 + DocumentInfo.debugString(mState.stack.peek())); 992 } 993 994 return new DirectoryLoader( 995 mInjector.features, 996 context, 997 mState, 998 contentsUri, 999 mInjector.fileTypeLookup, 1000 mContentLock, 1001 queryArgs); 1002 } 1003 } 1004 onCreateLoaderV2(int id, Bundle args)1005 private Loader<DirectoryResult> onCreateLoaderV2(int id, Bundle args) { 1006 if (mExecutorService == null) { 1007 // TODO(b:388130971): Fine tune the size of the thread pool. 1008 mExecutorService = Executors.newFixedThreadPool( 1009 GlobalSearchLoader.MAX_OUTSTANDING_TASK); 1010 } 1011 DocumentStack stack = mState.stack; 1012 RootInfo root = stack.getRoot(); 1013 List<UserId> userIdList = DocumentsApplication.getUserIdManager(mActivity).getUserIds(); 1014 1015 Duration lastModifiedDelta = stack.isRecents() 1016 ? Duration.ofMillis(RecentsLoader.REJECT_OLDER_THAN) 1017 : null; 1018 int maxResults = (root == null || root.isRecents()) 1019 ? RecentsLoader.MAX_DOCS_FROM_ROOT : MAX_RESULTS; 1020 QueryOptions options = new QueryOptions( 1021 maxResults, lastModifiedDelta, Duration.ofMillis(MAX_SEARCH_TIME_MS), 1022 mState.showHiddenFiles, mState.acceptMimes, mSearchMgr.buildQueryArgs()); 1023 1024 if (stack.isRecents() || mSearchMgr.isSearching()) { 1025 Log.d(TAG, "Creating search loader V2"); 1026 // For search and recent we create an observer that restart the loader every time 1027 // one of the searched content providers reports a change. 1028 final LockingContentObserver observer = new LockingContentObserver( 1029 mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack); 1030 Collection<RootInfo> rootList = new ArrayList<>(); 1031 if (stack.isRecents()) { 1032 // TODO(b:381346575): Pass roots based on user selection. 1033 rootList.addAll(mProviders.getMatchingRootsBlocking(mState).stream().filter( 1034 r -> r.supportsSearch() && r.authority != null 1035 && r.rootId != null).toList()); 1036 } else { 1037 rootList.add(root); 1038 } 1039 return new SearchLoader( 1040 mActivity, 1041 userIdList, 1042 mInjector.fileTypeLookup, 1043 observer, 1044 rootList, 1045 mSearchMgr.getCurrentSearch(), 1046 options, 1047 mState.sortModel, 1048 mExecutorService 1049 ); 1050 } 1051 Log.d(TAG, "Creating folder loader V2"); 1052 // For folder scan we pass the content lock to the loader so that it can register 1053 // an a callback to its internal method that forces a reload of the folder, every 1054 // time the content provider reports a change. 1055 return new FolderLoader( 1056 mActivity, 1057 userIdList, 1058 mInjector.fileTypeLookup, 1059 mContentLock, 1060 root, 1061 stack.peek(), 1062 options, 1063 mState.sortModel 1064 ); 1065 1066 } 1067 1068 @Override onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result)1069 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { 1070 if (DEBUG) { 1071 Log.d(TAG, "Loader has finished for: " 1072 + DocumentInfo.debugString(mState.stack.peek())); 1073 } 1074 assert (result != null); 1075 1076 mInjector.getModel().update(result); 1077 mLoaderSemaphore.release(); 1078 } 1079 1080 @Override onLoaderReset(Loader<DirectoryResult> loader)1081 public void onLoaderReset(Loader<DirectoryResult> loader) { 1082 mLoaderSemaphore.release(); 1083 } 1084 } 1085 1086 /** 1087 * A class primarily for the support of isolating our tests 1088 * from our concrete activity implementations. 1089 */ 1090 public interface CommonAddons { restoreRootAndDirectory()1091 void restoreRootAndDirectory(); 1092 refreshCurrentRootAndDirectory(@nimationType int anim)1093 void refreshCurrentRootAndDirectory(@AnimationType int anim); 1094 onRootPicked(RootInfo root)1095 void onRootPicked(RootInfo root); 1096 1097 // TODO: Move this to PickAddons as multi-document picking is exclusive to that activity. onDocumentsPicked(List<DocumentInfo> docs)1098 void onDocumentsPicked(List<DocumentInfo> docs); 1099 onDocumentPicked(DocumentInfo doc)1100 void onDocumentPicked(DocumentInfo doc); 1101 getCurrentRoot()1102 RootInfo getCurrentRoot(); 1103 getCurrentDirectory()1104 DocumentInfo getCurrentDirectory(); 1105 getSelectedUser()1106 UserId getSelectedUser(); 1107 1108 /** 1109 * Check whether current directory is root of recent. 1110 */ isInRecents()1111 boolean isInRecents(); 1112 setRootsDrawerOpen(boolean open)1113 void setRootsDrawerOpen(boolean open); 1114 1115 /** 1116 * Set the locked status of the DrawerController. 1117 */ setRootsDrawerLocked(boolean locked)1118 void setRootsDrawerLocked(boolean locked); 1119 1120 // TODO: Let navigator listens to State updateNavigator()1121 void updateNavigator(); 1122 1123 @VisibleForTesting notifyDirectoryNavigated(Uri docUri)1124 void notifyDirectoryNavigated(Uri docUri); 1125 } 1126 } 1127