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 23 import android.app.Activity; 24 import android.app.LoaderManager.LoaderCallbacks; 25 import android.app.PendingIntent; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.IntentSender; 29 import android.content.Loader; 30 import android.content.pm.ResolveInfo; 31 import android.database.Cursor; 32 import android.graphics.drawable.ColorDrawable; 33 import android.net.Uri; 34 import android.os.Bundle; 35 import android.os.Parcelable; 36 import android.provider.DocumentsContract; 37 import android.support.annotation.VisibleForTesting; 38 import android.util.Log; 39 import android.util.Pair; 40 import android.view.DragEvent; 41 42 import com.android.documentsui.AbstractActionHandler.CommonAddons; 43 import com.android.documentsui.LoadDocStackTask.LoadDocStackCallback; 44 import com.android.documentsui.base.BooleanConsumer; 45 import com.android.documentsui.base.DocumentInfo; 46 import com.android.documentsui.base.DocumentStack; 47 import com.android.documentsui.base.Lookup; 48 import com.android.documentsui.base.Providers; 49 import com.android.documentsui.base.RootInfo; 50 import com.android.documentsui.base.Shared; 51 import com.android.documentsui.base.State; 52 import com.android.documentsui.dirlist.AnimationView; 53 import com.android.documentsui.dirlist.AnimationView.AnimationType; 54 import com.android.documentsui.dirlist.FocusHandler; 55 import com.android.documentsui.files.LauncherActivity; 56 import com.android.documentsui.queries.SearchViewManager; 57 import com.android.documentsui.roots.GetRootDocumentTask; 58 import com.android.documentsui.roots.LoadRootTask; 59 import com.android.documentsui.roots.ProvidersAccess; 60 import com.android.documentsui.selection.ContentLock; 61 import com.android.documentsui.selection.MutableSelection; 62 import com.android.documentsui.selection.SelectionHelper; 63 import com.android.documentsui.selection.ItemDetailsLookup.ItemDetails; 64 import com.android.documentsui.sidebar.EjectRootTask; 65 import com.android.documentsui.ui.Snackbars; 66 67 import java.util.ArrayList; 68 import java.util.List; 69 import java.util.Objects; 70 import java.util.concurrent.Executor; 71 import java.util.function.Consumer; 72 73 import javax.annotation.Nullable; 74 75 /** 76 * Provides support for specializing the actions (openDocument etc.) to the host activity. 77 */ 78 public abstract class AbstractActionHandler<T extends Activity & CommonAddons> 79 implements ActionHandler { 80 81 @VisibleForTesting 82 public static final int CODE_FORWARD = 42; 83 public static final int CODE_AUTHENTICATION = 43; 84 85 @VisibleForTesting 86 static final int LOADER_ID = 42; 87 88 private static final String TAG = "AbstractActionHandler"; 89 private static final int REFRESH_SPINNER_TIMEOUT = 500; 90 91 protected final T mActivity; 92 protected final State mState; 93 protected final ProvidersAccess mProviders; 94 protected final DocumentsAccess mDocs; 95 protected final FocusHandler mFocusHandler; 96 protected final SelectionHelper mSelectionMgr; 97 protected final SearchViewManager mSearchMgr; 98 protected final Lookup<String, Executor> mExecutors; 99 protected final Injector<?> mInjector; 100 101 private final LoaderBindings mBindings; 102 103 private Runnable mDisplayStateChangedListener; 104 105 private ContentLock mContentLock; 106 107 @Override 108 public void registerDisplayStateChangedListener(Runnable l) { 109 mDisplayStateChangedListener = l; 110 } 111 @Override 112 public void unregisterDisplayStateChangedListener(Runnable l) { 113 if (mDisplayStateChangedListener == l) { 114 mDisplayStateChangedListener = null; 115 } 116 } 117 118 public AbstractActionHandler( 119 T activity, 120 State state, 121 ProvidersAccess providers, 122 DocumentsAccess docs, 123 SearchViewManager searchMgr, 124 Lookup<String, Executor> executors, 125 Injector<?> injector) { 126 127 assert(activity != null); 128 assert(state != null); 129 assert(providers != null); 130 assert(searchMgr != null); 131 assert(docs != null); 132 assert(injector != null); 133 134 mActivity = activity; 135 mState = state; 136 mProviders = providers; 137 mDocs = docs; 138 mFocusHandler = injector.focusManager; 139 mSelectionMgr = injector.selectionMgr; 140 mSearchMgr = searchMgr; 141 mExecutors = executors; 142 mInjector = injector; 143 144 mBindings = new LoaderBindings(); 145 } 146 147 @Override 148 public void ejectRoot(RootInfo root, BooleanConsumer listener) { 149 new EjectRootTask( 150 mActivity.getContentResolver(), 151 root.authority, 152 root.rootId, 153 listener).executeOnExecutor(ProviderExecutor.forAuthority(root.authority)); 154 } 155 156 @Override 157 public void startAuthentication(PendingIntent intent) { 158 try { 159 mActivity.startIntentSenderForResult(intent.getIntentSender(), CODE_AUTHENTICATION, 160 null, 0, 0, 0); 161 } catch (IntentSender.SendIntentException cancelled) { 162 Log.d(TAG, "Authentication Pending Intent either canceled or ignored."); 163 } 164 } 165 166 @Override 167 public void onActivityResult(int requestCode, int resultCode, Intent data) { 168 switch (requestCode) { 169 case CODE_AUTHENTICATION: 170 onAuthenticationResult(resultCode); 171 break; 172 } 173 } 174 175 private void onAuthenticationResult(int resultCode) { 176 if (resultCode == Activity.RESULT_OK) { 177 Log.v(TAG, "Authentication was successful. Refreshing directory now."); 178 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 179 } 180 } 181 182 @Override 183 public void getRootDocument(RootInfo root, int timeout, Consumer<DocumentInfo> callback) { 184 GetRootDocumentTask task = new GetRootDocumentTask( 185 root, 186 mActivity, 187 timeout, 188 mDocs, 189 callback); 190 191 task.executeOnExecutor(mExecutors.lookup(root.authority)); 192 } 193 194 @Override 195 public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) { 196 RefreshTask task = new RefreshTask( 197 mInjector.features, 198 mState, 199 doc, 200 REFRESH_SPINNER_TIMEOUT, 201 mActivity.getApplicationContext(), 202 mActivity::isDestroyed, 203 callback); 204 task.executeOnExecutor(mExecutors.lookup(doc == null ? null : doc.authority)); 205 } 206 207 @Override 208 public void openSelectedInNewWindow() { 209 throw new UnsupportedOperationException("Can't open in new window."); 210 } 211 212 @Override 213 public void openInNewWindow(DocumentStack path) { 214 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_NEW_WINDOW); 215 216 Intent intent = LauncherActivity.createLaunchIntent(mActivity); 217 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) path); 218 219 // Multi-window necessitates we pick how we are launched. 220 // By default we'd be launched in-place above the existing app. 221 // By setting launch-to-side ActivityManager will open us to side. 222 if (mActivity.isInMultiWindowMode()) { 223 intent.addFlags(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT); 224 } 225 226 mActivity.startActivity(intent); 227 } 228 229 @Override 230 public boolean openItem(ItemDetails doc, @ViewType int type, @ViewType int fallback) { 231 throw new UnsupportedOperationException("Can't open document."); 232 } 233 234 @Override 235 public void showInspector(DocumentInfo doc) { 236 throw new UnsupportedOperationException("Can't open properties."); 237 } 238 239 @Override 240 public void springOpenDirectory(DocumentInfo doc) { 241 throw new UnsupportedOperationException("Can't spring open directories."); 242 } 243 244 @Override 245 public void openSettings(RootInfo root) { 246 throw new UnsupportedOperationException("Can't open settings."); 247 } 248 249 @Override 250 public void openRoot(ResolveInfo app) { 251 throw new UnsupportedOperationException("Can't open an app."); 252 } 253 254 @Override 255 public void showAppDetails(ResolveInfo info) { 256 throw new UnsupportedOperationException("Can't show app details."); 257 } 258 259 @Override 260 public boolean dropOn(DragEvent event, RootInfo root) { 261 throw new UnsupportedOperationException("Can't open an app."); 262 } 263 264 @Override 265 public void pasteIntoFolder(RootInfo root) { 266 throw new UnsupportedOperationException("Can't paste into folder."); 267 } 268 269 @Override 270 public void viewInOwner() { 271 throw new UnsupportedOperationException("Can't view in application."); 272 } 273 274 @Override 275 public void selectAllFiles() { 276 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_SELECT_ALL); 277 Model model = mInjector.getModel(); 278 279 // Exclude disabled files 280 List<String> enabled = new ArrayList<>(); 281 for (String id : model.getModelIds()) { 282 Cursor cursor = model.getItem(id); 283 if (cursor == null) { 284 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id); 285 continue; 286 } 287 String docMimeType = getCursorString( 288 cursor, DocumentsContract.Document.COLUMN_MIME_TYPE); 289 int docFlags = getCursorInt(cursor, DocumentsContract.Document.COLUMN_FLAGS); 290 if (mInjector.config.isDocumentEnabled(docMimeType, docFlags, mState)) { 291 enabled.add(id); 292 } 293 } 294 295 // Only select things currently visible in the adapter. 296 boolean changed = mSelectionMgr.setItemsSelected(enabled, true); 297 if (changed) { 298 mDisplayStateChangedListener.run(); 299 } 300 } 301 302 @Override 303 public void showCreateDirectoryDialog() { 304 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_CREATE_DIR); 305 306 CreateDirectoryFragment.show(mActivity.getFragmentManager()); 307 } 308 309 @Override 310 @Nullable 311 public DocumentInfo renameDocument(String name, DocumentInfo document) { 312 throw new UnsupportedOperationException("Can't rename documents."); 313 } 314 315 @Override 316 public void showChooserForDoc(DocumentInfo doc) { 317 throw new UnsupportedOperationException("Show chooser for doc not supported!"); 318 } 319 320 @Override 321 public void openRootDocument(@Nullable DocumentInfo rootDoc) { 322 if (rootDoc == null) { 323 // There are 2 cases where rootDoc is null -- 1) loading recents; 2) failed to load root 324 // document. Either case we should call refreshCurrentRootAndDirectory() to let 325 // DirectoryFragment update UI. 326 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 327 } else { 328 openContainerDocument(rootDoc); 329 } 330 } 331 332 @Override 333 public void openContainerDocument(DocumentInfo doc) { 334 assert(doc.isContainer()); 335 336 if (mSearchMgr.isSearching()) { 337 loadDocument( 338 doc.derivedUri, 339 (@Nullable DocumentStack stack) -> openFolderInSearchResult(stack, doc)); 340 } else { 341 openChildContainer(doc); 342 } 343 } 344 345 private void openFolderInSearchResult(@Nullable DocumentStack stack, DocumentInfo doc) { 346 if (stack == null) { 347 mState.stack.popToRootDocument(); 348 349 // Update navigator to give horizontal breadcrumb a chance to update documents. It 350 // doesn't update its content if the size of document stack doesn't change. 351 // TODO: update breadcrumb to take range update. 352 mActivity.updateNavigator(); 353 354 mState.stack.push(doc); 355 } else { 356 if (!Objects.equals(mState.stack.getRoot(), stack.getRoot())) { 357 Log.w(TAG, "Provider returns " + stack.getRoot() + " rather than expected " 358 + mState.stack.getRoot()); 359 } 360 361 final DocumentInfo top = stack.peek(); 362 if (top.isArchive()) { 363 // Swap the zip file in original provider and the one provided by ArchiveProvider. 364 stack.pop(); 365 stack.push(mDocs.getArchiveDocument(top.derivedUri)); 366 } 367 368 mState.stack.reset(); 369 // Update navigator to give horizontal breadcrumb a chance to update documents. It 370 // doesn't update its content if the size of document stack doesn't change. 371 // TODO: update breadcrumb to take range update. 372 mActivity.updateNavigator(); 373 374 mState.stack.reset(stack); 375 } 376 377 // Show an opening animation only if pressing "back" would get us back to the 378 // previous directory. Especially after opening a root document, pressing 379 // back, wouldn't go to the previous root, but close the activity. 380 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 381 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 382 mActivity.refreshCurrentRootAndDirectory(anim); 383 } 384 385 private void openChildContainer(DocumentInfo doc) { 386 DocumentInfo currentDoc = null; 387 388 if (doc.isDirectory()) { 389 // Regular directory. 390 currentDoc = doc; 391 } else if (doc.isArchive()) { 392 // Archive. 393 currentDoc = mDocs.getArchiveDocument(doc.derivedUri); 394 } 395 396 assert(currentDoc != null); 397 mActivity.notifyDirectoryNavigated(currentDoc.derivedUri); 398 399 mState.stack.push(currentDoc); 400 // Show an opening animation only if pressing "back" would get us back to the 401 // previous directory. Especially after opening a root document, pressing 402 // back, wouldn't go to the previous root, but close the activity. 403 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 404 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 405 mActivity.refreshCurrentRootAndDirectory(anim); 406 } 407 408 @Override 409 public void setDebugMode(boolean enabled) { 410 if (!mInjector.features.isDebugSupportEnabled()) { 411 return; 412 } 413 414 mState.debugMode = enabled; 415 mInjector.features.forceFeature(R.bool.feature_command_interceptor, enabled); 416 mInjector.features.forceFeature(R.bool.feature_inspector, enabled); 417 mActivity.invalidateOptionsMenu(); 418 419 if (enabled) { 420 showDebugMessage(); 421 } else { 422 mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable( 423 mActivity.getResources().getColor(R.color.primary))); 424 mActivity.getWindow().setStatusBarColor( 425 mActivity.getResources().getColor(R.color.primary_dark)); 426 } 427 } 428 429 @Override 430 public void showDebugMessage() { 431 assert (mInjector.features.isDebugSupportEnabled()); 432 433 int[] colors = mInjector.debugHelper.getNextColors(); 434 Pair<String, Integer> messagePair = mInjector.debugHelper.getNextMessage(); 435 436 Snackbars.showCustomTextWithImage(mActivity, messagePair.first, messagePair.second); 437 438 mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable(colors[0])); 439 mActivity.getWindow().setStatusBarColor(colors[1]); 440 } 441 442 @Override 443 public void cutToClipboard() { 444 throw new UnsupportedOperationException("Cut not supported!"); 445 } 446 447 @Override 448 public void copyToClipboard() { 449 throw new UnsupportedOperationException("Copy not supported!"); 450 } 451 452 @Override 453 public void deleteSelectedDocuments() { 454 throw new UnsupportedOperationException("Delete not supported!"); 455 } 456 457 @Override 458 public void shareSelectedDocuments() { 459 throw new UnsupportedOperationException("Share not supported!"); 460 } 461 462 protected final void loadDocument(Uri uri, LoadDocStackCallback callback) { 463 new LoadDocStackTask( 464 mActivity, 465 mProviders, 466 mDocs, 467 callback 468 ).executeOnExecutor(mExecutors.lookup(uri.getAuthority()), uri); 469 } 470 471 @Override 472 public final void loadRoot(Uri uri) { 473 new LoadRootTask<>(mActivity, mProviders, mState, uri) 474 .executeOnExecutor(mExecutors.lookup(uri.getAuthority())); 475 } 476 477 @Override 478 public void loadDocumentsForCurrentStack() { 479 DocumentStack stack = mState.stack; 480 if (!stack.isRecents() && stack.isEmpty()) { 481 DirectoryResult result = new DirectoryResult(); 482 483 // TODO (b/35996595): Consider plumbing through the actual exception, though it might 484 // not be very useful (always pointing to DatabaseUtils#readExceptionFromParcel()). 485 result.exception = new IllegalStateException("Failed to load root document."); 486 mInjector.getModel().update(result); 487 return; 488 } 489 490 mActivity.getLoaderManager().restartLoader(LOADER_ID, null, mBindings); 491 } 492 493 protected final boolean launchToDocument(Uri uri) { 494 // We don't support launching to a document in an archive. 495 if (!Providers.isArchiveUri(uri)) { 496 loadDocument(uri, this::onStackLoaded); 497 return true; 498 } 499 500 return false; 501 } 502 503 private void onStackLoaded(@Nullable DocumentStack stack) { 504 if (stack != null) { 505 if (!stack.peek().isDirectory()) { 506 // Requested document is not a directory. Pop it so that we can launch into its 507 // parent. 508 stack.pop(); 509 } 510 mState.stack.reset(stack); 511 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 512 513 Metrics.logLaunchAtLocation(mActivity, mState, stack.getRoot().getUri()); 514 } else { 515 Log.w(TAG, "Failed to launch into the given uri. Launch to default location."); 516 launchToDefaultLocation(); 517 518 Metrics.logLaunchAtLocation(mActivity, mState, null); 519 } 520 } 521 522 protected abstract void launchToDefaultLocation(); 523 524 protected void restoreRootAndDirectory() { 525 if (!mState.stack.getRoot().isRecents() && mState.stack.isEmpty()) { 526 mActivity.onRootPicked(mState.stack.getRoot()); 527 } else { 528 mActivity.restoreRootAndDirectory(); 529 } 530 } 531 532 protected final void loadHomeDir() { 533 loadRoot(Shared.getDefaultRootUri(mActivity)); 534 } 535 536 protected MutableSelection getStableSelection() { 537 MutableSelection selection = new MutableSelection(); 538 mSelectionMgr.copySelection(selection); 539 return selection; 540 } 541 542 @Override 543 public ActionHandler reset(ContentLock reloadLock) { 544 mContentLock = reloadLock; 545 mActivity.getLoaderManager().destroyLoader(LOADER_ID); 546 return this; 547 } 548 549 private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> { 550 551 @Override 552 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { 553 Context context = mActivity; 554 555 if (mState.stack.isRecents()) { 556 557 if (DEBUG) Log.d(TAG, "Creating new loader recents."); 558 return new RecentsLoader( 559 context, 560 mProviders, 561 mState, 562 mInjector.features, 563 mExecutors, 564 mInjector.fileTypeLookup); 565 } else { 566 567 Uri contentsUri = mSearchMgr.isSearching() 568 ? DocumentsContract.buildSearchDocumentsUri( 569 mState.stack.getRoot().authority, 570 mState.stack.getRoot().rootId, 571 mSearchMgr.getCurrentSearch()) 572 : DocumentsContract.buildChildDocumentsUri( 573 mState.stack.peek().authority, 574 mState.stack.peek().documentId); 575 576 if (mInjector.config.managedModeEnabled(mState.stack)) { 577 contentsUri = DocumentsContract.setManageMode(contentsUri); 578 } 579 580 if (DEBUG) Log.d(TAG, 581 "Creating new directory loader for: " 582 + DocumentInfo.debugString(mState.stack.peek())); 583 584 return new DirectoryLoader( 585 mInjector.features, 586 context, 587 mState.stack.getRoot(), 588 mState.stack.peek(), 589 contentsUri, 590 mState.sortModel, 591 mInjector.fileTypeLookup, 592 mContentLock, 593 mSearchMgr.isSearching()); 594 } 595 } 596 597 @Override 598 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { 599 if (DEBUG) Log.d(TAG, "Loader has finished for: " 600 + DocumentInfo.debugString(mState.stack.peek())); 601 assert(result != null); 602 603 mInjector.getModel().update(result); 604 } 605 606 @Override 607 public void onLoaderReset(Loader<DirectoryResult> loader) {} 608 } 609 /** 610 * A class primarily for the support of isolating our tests 611 * from our concrete activity implementations. 612 */ 613 public interface CommonAddons { 614 void restoreRootAndDirectory(); 615 void refreshCurrentRootAndDirectory(@AnimationType int anim); 616 void onRootPicked(RootInfo root); 617 // TODO: Move this to PickAddons as multi-document picking is exclusive to that activity. 618 void onDocumentsPicked(List<DocumentInfo> docs); 619 void onDocumentPicked(DocumentInfo doc); 620 RootInfo getCurrentRoot(); 621 DocumentInfo getCurrentDirectory(); 622 void setRootsDrawerOpen(boolean open); 623 624 // TODO: Let navigator listens to State 625 void updateNavigator(); 626 627 @VisibleForTesting 628 void notifyDirectoryNavigated(Uri docUri); 629 } 630 } 631