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.Shared.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.Features; 48 import com.android.documentsui.base.Lookup; 49 import com.android.documentsui.base.Providers; 50 import com.android.documentsui.base.RootInfo; 51 import com.android.documentsui.base.Shared; 52 import com.android.documentsui.base.State; 53 import com.android.documentsui.dirlist.AnimationView; 54 import com.android.documentsui.dirlist.AnimationView.AnimationType; 55 import com.android.documentsui.dirlist.DocumentDetails; 56 import com.android.documentsui.dirlist.FocusHandler; 57 import com.android.documentsui.files.LauncherActivity; 58 import com.android.documentsui.queries.SearchViewManager; 59 import com.android.documentsui.roots.GetRootDocumentTask; 60 import com.android.documentsui.roots.LoadRootTask; 61 import com.android.documentsui.roots.ProvidersAccess; 62 import com.android.documentsui.selection.Selection; 63 import com.android.documentsui.selection.SelectionManager; 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 SelectionManager 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 DirectoryReloadLock mDirectoryReloadLock; 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 openDocument(DocumentDetails doc, @ViewType int type, @ViewType int fallback) { 231 throw new UnsupportedOperationException("Can't open document."); 232 } 233 234 @Override 235 public void springOpenDirectory(DocumentInfo doc) { 236 throw new UnsupportedOperationException("Can't spring open directories."); 237 } 238 239 @Override 240 public void openSettings(RootInfo root) { 241 throw new UnsupportedOperationException("Can't open settings."); 242 } 243 244 @Override 245 public void openRoot(ResolveInfo app) { 246 throw new UnsupportedOperationException("Can't open an app."); 247 } 248 249 @Override 250 public void showAppDetails(ResolveInfo info) { 251 throw new UnsupportedOperationException("Can't show app details."); 252 } 253 254 @Override 255 public boolean dropOn(DragEvent event, RootInfo root) { 256 throw new UnsupportedOperationException("Can't open an app."); 257 } 258 259 @Override 260 public void pasteIntoFolder(RootInfo root) { 261 throw new UnsupportedOperationException("Can't paste into folder."); 262 } 263 264 @Override 265 public void viewInOwner() { 266 throw new UnsupportedOperationException("Can't view in application."); 267 } 268 269 @Override 270 public void selectAllFiles() { 271 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_SELECT_ALL); 272 Model model = mInjector.getModel(); 273 274 // Exclude disabled files 275 List<String> enabled = new ArrayList<>(); 276 for (String id : model.getModelIds()) { 277 Cursor cursor = model.getItem(id); 278 if (cursor == null) { 279 Log.w(TAG, "Skipping selection. Can't obtain cursor for modeId: " + id); 280 continue; 281 } 282 String docMimeType = getCursorString( 283 cursor, DocumentsContract.Document.COLUMN_MIME_TYPE); 284 int docFlags = getCursorInt(cursor, DocumentsContract.Document.COLUMN_FLAGS); 285 if (mInjector.config.isDocumentEnabled(docMimeType, docFlags, mState)) { 286 enabled.add(id); 287 } 288 } 289 290 // Only select things currently visible in the adapter. 291 boolean changed = mSelectionMgr.setItemsSelected(enabled, true); 292 if (changed) { 293 mDisplayStateChangedListener.run(); 294 } 295 } 296 297 @Override 298 @Nullable 299 public DocumentInfo renameDocument(String name, DocumentInfo document) { 300 throw new UnsupportedOperationException("Can't rename documents."); 301 } 302 303 @Override 304 public void showChooserForDoc(DocumentInfo doc) { 305 throw new UnsupportedOperationException("Show chooser for doc not supported!"); 306 } 307 308 @Override 309 public void openRootDocument(@Nullable DocumentInfo rootDoc) { 310 if (rootDoc == null) { 311 // There are 2 cases where rootDoc is null -- 1) loading recents; 2) failed to load root 312 // document. Either case we should call refreshCurrentRootAndDirectory() to let 313 // DirectoryFragment update UI. 314 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 315 } else { 316 openContainerDocument(rootDoc); 317 } 318 } 319 320 @Override 321 public void openContainerDocument(DocumentInfo doc) { 322 assert(doc.isContainer()); 323 324 if (mSearchMgr.isSearching()) { 325 loadDocument( 326 doc.derivedUri, 327 (@Nullable DocumentStack stack) -> openFolderInSearchResult(stack, doc)); 328 } else { 329 openChildContainer(doc); 330 } 331 } 332 333 private void openFolderInSearchResult(@Nullable DocumentStack stack, DocumentInfo doc) { 334 if (stack == null) { 335 mState.stack.popToRootDocument(); 336 337 // Update navigator to give horizontal breadcrumb a chance to update documents. It 338 // doesn't update its content if the size of document stack doesn't change. 339 // TODO: update breadcrumb to take range update. 340 mActivity.updateNavigator(); 341 342 mState.stack.push(doc); 343 } else { 344 if (!Objects.equals(mState.stack.getRoot(), stack.getRoot())) { 345 Log.w(TAG, "Provider returns " + stack.getRoot() + " rather than expected " 346 + mState.stack.getRoot()); 347 } 348 349 mState.stack.reset(); 350 // Update navigator to give horizontal breadcrumb a chance to update documents. It 351 // doesn't update its content if the size of document stack doesn't change. 352 // TODO: update breadcrumb to take range update. 353 mActivity.updateNavigator(); 354 355 mState.stack.reset(stack); 356 } 357 358 // Show an opening animation only if pressing "back" would get us back to the 359 // previous directory. Especially after opening a root document, pressing 360 // back, wouldn't go to the previous root, but close the activity. 361 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 362 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 363 mActivity.refreshCurrentRootAndDirectory(anim); 364 } 365 366 private void openChildContainer(DocumentInfo doc) { 367 DocumentInfo currentDoc = null; 368 369 if (doc.isDirectory()) { 370 // Regular directory. 371 currentDoc = doc; 372 } else if (doc.isArchive()) { 373 // Archive. 374 currentDoc = mDocs.getArchiveDocument(doc.derivedUri); 375 } 376 377 assert(currentDoc != null); 378 mActivity.notifyDirectoryNavigated(currentDoc.derivedUri); 379 380 mState.stack.push(currentDoc); 381 // Show an opening animation only if pressing "back" would get us back to the 382 // previous directory. Especially after opening a root document, pressing 383 // back, wouldn't go to the previous root, but close the activity. 384 final int anim = (mState.stack.hasLocationChanged() && mState.stack.size() > 1) 385 ? AnimationView.ANIM_ENTER : AnimationView.ANIM_NONE; 386 mActivity.refreshCurrentRootAndDirectory(anim); 387 } 388 389 @Override 390 public void setDebugMode(boolean enabled) { 391 if (!mInjector.features.isDebugSupportEnabled()) { 392 return; 393 } 394 395 mState.debugMode = enabled; 396 mInjector.features.forceFeature(R.bool.feature_command_interceptor, enabled); 397 mActivity.invalidateOptionsMenu(); 398 399 if (enabled) { 400 showDebugMessage(); 401 } else { 402 mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable( 403 mActivity.getResources().getColor(R.color.primary))); 404 mActivity.getWindow().setStatusBarColor( 405 mActivity.getResources().getColor(R.color.primary_dark)); 406 } 407 } 408 409 @Override 410 public void showDebugMessage() { 411 assert (mInjector.features.isDebugSupportEnabled()); 412 413 int[] colors = mInjector.debugHelper.getNextColors(); 414 Pair<String, Integer> messagePair = mInjector.debugHelper.getNextMessage(); 415 416 Snackbars.showCustomTextWithImage(mActivity, messagePair.first, messagePair.second); 417 418 mActivity.getActionBar().setBackgroundDrawable(new ColorDrawable(colors[0])); 419 mActivity.getWindow().setStatusBarColor(colors[1]); 420 } 421 422 @Override 423 public void cutToClipboard() { 424 throw new UnsupportedOperationException("Cut not supported!"); 425 } 426 427 @Override 428 public void copyToClipboard() { 429 throw new UnsupportedOperationException("Copy not supported!"); 430 } 431 432 @Override 433 public void deleteSelectedDocuments() { 434 throw new UnsupportedOperationException("Delete not supported!"); 435 } 436 437 @Override 438 public void shareSelectedDocuments() { 439 throw new UnsupportedOperationException("Share not supported!"); 440 } 441 442 protected final void loadDocument(Uri uri, LoadDocStackCallback callback) { 443 new LoadDocStackTask( 444 mActivity, 445 mProviders, 446 mDocs, 447 callback 448 ).executeOnExecutor(mExecutors.lookup(uri.getAuthority()), uri); 449 } 450 451 @Override 452 public final void loadRoot(Uri uri) { 453 new LoadRootTask<>(mActivity, mProviders, mState, uri) 454 .executeOnExecutor(mExecutors.lookup(uri.getAuthority())); 455 } 456 457 @Override 458 public void loadDocumentsForCurrentStack() { 459 DocumentStack stack = mState.stack; 460 if (!stack.isRecents() && stack.isEmpty()) { 461 DirectoryResult result = new DirectoryResult(); 462 463 // TODO (b/35996595): Consider plumbing through the actual exception, though it might 464 // not be very useful (always pointing to DatabaseUtils#readExceptionFromParcel()). 465 result.exception = new IllegalStateException("Failed to load root document."); 466 mInjector.getModel().update(result); 467 return; 468 } 469 470 mActivity.getLoaderManager().restartLoader(LOADER_ID, null, mBindings); 471 } 472 473 protected final boolean launchToDocument(Uri uri) { 474 // We don't support launching to a document in an archive. 475 if (!Providers.isArchiveUri(uri)) { 476 loadDocument(uri, this::onStackLoaded); 477 return true; 478 } 479 480 return false; 481 } 482 483 private void onStackLoaded(@Nullable DocumentStack stack) { 484 if (stack != null) { 485 if (!stack.peek().isDirectory()) { 486 // Requested document is not a directory. Pop it so that we can launch into its 487 // parent. 488 stack.pop(); 489 } 490 mState.stack.reset(stack); 491 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 492 493 Metrics.logLaunchAtLocation(mActivity, mState, stack.getRoot().getUri()); 494 } else { 495 Log.w(TAG, "Failed to launch into the given uri. Launch to default location."); 496 launchToDefaultLocation(); 497 498 Metrics.logLaunchAtLocation(mActivity, mState, null); 499 } 500 } 501 502 protected abstract void launchToDefaultLocation(); 503 504 protected final void loadHomeDir() { 505 loadRoot(Shared.getDefaultRootUri(mActivity)); 506 } 507 508 protected Selection getStableSelection() { 509 return mSelectionMgr.getSelection(new Selection()); 510 } 511 512 @Override 513 public ActionHandler reset(DirectoryReloadLock reloadLock) { 514 mDirectoryReloadLock = reloadLock; 515 mActivity.getLoaderManager().destroyLoader(LOADER_ID); 516 return this; 517 } 518 519 private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> { 520 521 @Override 522 public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { 523 Context context = mActivity; 524 525 if (mState.stack.isRecents()) { 526 527 if (DEBUG) Log.d(TAG, "Creating new loader recents."); 528 return new RecentsLoader(context, mProviders, mState, mInjector.features); 529 530 } else { 531 532 Uri contentsUri = mSearchMgr.isSearching() 533 ? DocumentsContract.buildSearchDocumentsUri( 534 mState.stack.getRoot().authority, 535 mState.stack.getRoot().rootId, 536 mSearchMgr.getCurrentSearch()) 537 : DocumentsContract.buildChildDocumentsUri( 538 mState.stack.peek().authority, 539 mState.stack.peek().documentId); 540 541 if (mInjector.config.managedModeEnabled(mState.stack)) { 542 contentsUri = DocumentsContract.setManageMode(contentsUri); 543 } 544 545 if (DEBUG) Log.d(TAG, 546 "Creating new directory loader for: " 547 + DocumentInfo.debugString(mState.stack.peek())); 548 549 return new DirectoryLoader( 550 mInjector.features, 551 context, 552 mState.stack.getRoot(), 553 mState.stack.peek(), 554 contentsUri, 555 mState.sortModel, 556 mDirectoryReloadLock, 557 mSearchMgr.isSearching()); 558 } 559 } 560 561 @Override 562 public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { 563 if (DEBUG) Log.d(TAG, "Loader has finished for: " 564 + DocumentInfo.debugString(mState.stack.peek())); 565 assert(result != null); 566 567 mInjector.getModel().update(result); 568 } 569 570 @Override 571 public void onLoaderReset(Loader<DirectoryResult> loader) {} 572 } 573 /** 574 * A class primarily for the support of isolating our tests 575 * from our concrete activity implementations. 576 */ 577 public interface CommonAddons { 578 void refreshCurrentRootAndDirectory(@AnimationType int anim); 579 void onRootPicked(RootInfo root); 580 // TODO: Move this to PickAddons as multi-document picking is exclusive to that activity. 581 void onDocumentsPicked(List<DocumentInfo> docs); 582 void onDocumentPicked(DocumentInfo doc); 583 RootInfo getCurrentRoot(); 584 DocumentInfo getCurrentDirectory(); 585 void setRootsDrawerOpen(boolean open); 586 587 // TODO: Let navigator listens to State 588 void updateNavigator(); 589 590 @VisibleForTesting 591 void notifyDirectoryNavigated(Uri docUri); 592 } 593 } 594