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.files; 18 19 import static com.android.documentsui.base.Shared.DEBUG; 20 21 import android.app.Activity; 22 import android.content.ActivityNotFoundException; 23 import android.content.ClipData; 24 import android.content.ContentProviderClient; 25 import android.content.ContentResolver; 26 import android.content.Intent; 27 import android.net.Uri; 28 import android.provider.DocumentsContract; 29 import android.util.Log; 30 import android.view.DragEvent; 31 32 import com.android.documentsui.AbstractActionHandler; 33 import com.android.documentsui.ActionModeAddons; 34 import com.android.documentsui.ActivityConfig; 35 import com.android.documentsui.DocumentsAccess; 36 import com.android.documentsui.DocumentsApplication; 37 import com.android.documentsui.DragAndDropManager; 38 import com.android.documentsui.Injector; 39 import com.android.documentsui.Metrics; 40 import com.android.documentsui.Model; 41 import com.android.documentsui.R; 42 import com.android.documentsui.TimeoutTask; 43 import com.android.documentsui.base.ConfirmationCallback; 44 import com.android.documentsui.base.ConfirmationCallback.Result; 45 import com.android.documentsui.base.DebugFlags; 46 import com.android.documentsui.base.DocumentFilters; 47 import com.android.documentsui.base.DocumentInfo; 48 import com.android.documentsui.base.DocumentStack; 49 import com.android.documentsui.base.Features; 50 import com.android.documentsui.base.Lookup; 51 import com.android.documentsui.base.MimeTypes; 52 import com.android.documentsui.base.RootInfo; 53 import com.android.documentsui.base.Shared; 54 import com.android.documentsui.base.State; 55 import com.android.documentsui.clipping.ClipStore; 56 import com.android.documentsui.clipping.DocumentClipper; 57 import com.android.documentsui.clipping.UrisSupplier; 58 import com.android.documentsui.dirlist.AnimationView; 59 import com.android.documentsui.dirlist.DocumentDetails; 60 import com.android.documentsui.files.ActionHandler.Addons; 61 import com.android.documentsui.inspector.InspectorActivity; 62 import com.android.documentsui.queries.SearchViewManager; 63 import com.android.documentsui.roots.ProvidersAccess; 64 import com.android.documentsui.selection.Selection; 65 import com.android.documentsui.services.FileOperation; 66 import com.android.documentsui.services.FileOperationService; 67 import com.android.documentsui.services.FileOperations; 68 import com.android.documentsui.ui.DialogController; 69 import com.android.internal.annotations.VisibleForTesting; 70 71 import java.util.ArrayList; 72 import java.util.List; 73 import java.util.concurrent.Executor; 74 75 import javax.annotation.Nullable; 76 77 /** 78 * Provides {@link FilesActivity} action specializations to fragments. 79 */ 80 public class ActionHandler<T extends Activity & Addons> extends AbstractActionHandler<T> { 81 82 private static final String TAG = "ManagerActionHandler"; 83 84 private final ActionModeAddons mActionModeAddons; 85 private final Features mFeatures; 86 private final ActivityConfig mConfig; 87 private final DialogController mDialogs; 88 private final DocumentClipper mClipper; 89 private final ClipStore mClipStore; 90 private final DragAndDropManager mDragAndDropManager; 91 private final Model mModel; 92 93 ActionHandler( 94 T activity, 95 State state, 96 ProvidersAccess providers, 97 DocumentsAccess docs, 98 SearchViewManager searchMgr, 99 Lookup<String, Executor> executors, 100 ActionModeAddons actionModeAddons, 101 DocumentClipper clipper, 102 ClipStore clipStore, 103 DragAndDropManager dragAndDropManager, 104 Injector injector) { 105 106 super(activity, state, providers, docs, searchMgr, executors, injector); 107 108 mActionModeAddons = actionModeAddons; 109 mFeatures = injector.features; 110 mConfig = injector.config; 111 mDialogs = injector.dialogs; 112 mClipper = clipper; 113 mClipStore = clipStore; 114 mDragAndDropManager = dragAndDropManager; 115 mModel = injector.getModel(); 116 } 117 118 @Override 119 public boolean dropOn(DragEvent event, RootInfo root) { 120 if (!root.supportsCreate() || root.isLibrary()) { 121 return false; 122 } 123 124 // DragEvent gets recycled, so it is possible that by the time the callback is called, 125 // event.getLocalState() and event.getClipData() returns null. Thus, we want to save 126 // references to ensure they are non null. 127 final ClipData clipData = event.getClipData(); 128 final Object localState = event.getLocalState(); 129 130 return mDragAndDropManager.drop( 131 clipData, localState, root, this, mDialogs::showFileOperationStatus); 132 } 133 134 @Override 135 public void openSelectedInNewWindow() { 136 Selection selection = getStableSelection(); 137 assert(selection.size() == 1); 138 DocumentInfo doc = mModel.getDocument(selection.iterator().next()); 139 assert(doc != null); 140 openInNewWindow(new DocumentStack(mState.stack, doc)); 141 } 142 143 @Override 144 public void openSettings(RootInfo root) { 145 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_SETTINGS); 146 final Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_ROOT_SETTINGS); 147 intent.setDataAndType(root.getUri(), DocumentsContract.Root.MIME_TYPE_ITEM); 148 mActivity.startActivity(intent); 149 } 150 151 @Override 152 public void pasteIntoFolder(RootInfo root) { 153 this.getRootDocument( 154 root, 155 TimeoutTask.DEFAULT_TIMEOUT, 156 (DocumentInfo doc) -> pasteIntoFolder(root, doc)); 157 } 158 159 private void pasteIntoFolder(RootInfo root, @Nullable DocumentInfo doc) { 160 DocumentStack stack = new DocumentStack(root, doc); 161 mClipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationStatus); 162 } 163 164 @Override 165 public @Nullable DocumentInfo renameDocument(String name, DocumentInfo document) { 166 ContentResolver resolver = mActivity.getContentResolver(); 167 ContentProviderClient client = null; 168 169 try { 170 client = DocumentsApplication.acquireUnstableProviderOrThrow( 171 resolver, document.derivedUri.getAuthority()); 172 Uri newUri = DocumentsContract.renameDocument( 173 client, document.derivedUri, name); 174 return DocumentInfo.fromUri(resolver, newUri); 175 } catch (Exception e) { 176 Log.w(TAG, "Failed to rename file", e); 177 return null; 178 } finally { 179 ContentProviderClient.releaseQuietly(client); 180 } 181 } 182 183 @Override 184 public void openRoot(RootInfo root) { 185 Metrics.logRootVisited(mActivity, Metrics.FILES_SCOPE, root); 186 mActivity.onRootPicked(root); 187 } 188 189 @Override 190 public boolean openDocument(DocumentDetails details, @ViewType int type, 191 @ViewType int fallback) { 192 DocumentInfo doc = mModel.getDocument(details.getModelId()); 193 if (doc == null) { 194 Log.w(TAG, 195 "Can't view item. No Document available for modeId: " + details.getModelId()); 196 return false; 197 } 198 199 return openDocument(doc, type, fallback); 200 } 201 202 // TODO: Make this private and make tests call openDocument(DocumentDetails, int, int) instead. 203 @VisibleForTesting 204 public boolean openDocument(DocumentInfo doc, @ViewType int type, @ViewType int fallback) { 205 if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) { 206 onDocumentPicked(doc, type, fallback); 207 mSelectionMgr.clearSelection(); 208 return true; 209 } 210 return false; 211 } 212 213 @Override 214 public void springOpenDirectory(DocumentInfo doc) { 215 assert(doc.isDirectory()); 216 mActionModeAddons.finishActionMode(); 217 openContainerDocument(doc); 218 } 219 220 private Selection getSelectedOrFocused() { 221 final Selection selection = this.getStableSelection(); 222 if (selection.isEmpty()) { 223 String focusModelId = mFocusHandler.getFocusModelId(); 224 if (focusModelId != null) { 225 selection.add(focusModelId); 226 } 227 } 228 229 return selection; 230 } 231 232 @Override 233 public void cutToClipboard() { 234 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_CUT_CLIPBOARD); 235 Selection selection = getSelectedOrFocused(); 236 237 if (selection.isEmpty()) { 238 return; 239 } 240 241 if (mModel.hasDocuments(selection, DocumentFilters.NOT_MOVABLE)) { 242 mDialogs.showOperationUnsupported(); 243 return; 244 } 245 246 mSelectionMgr.clearSelection(); 247 248 mClipper.clipDocumentsForCut(mModel::getItemUri, selection, mState.stack.peek()); 249 250 mDialogs.showDocumentsClipped(selection.size()); 251 } 252 253 @Override 254 public void copyToClipboard() { 255 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_COPY_CLIPBOARD); 256 Selection selection = getSelectedOrFocused(); 257 258 if (selection.isEmpty()) { 259 return; 260 } 261 mSelectionMgr.clearSelection(); 262 263 mClipper.clipDocumentsForCopy(mModel::getItemUri, selection); 264 265 mDialogs.showDocumentsClipped(selection.size()); 266 } 267 268 @Override 269 public void viewInOwner() { 270 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_VIEW_IN_APPLICATION); 271 Selection selection = getSelectedOrFocused(); 272 273 if (selection.isEmpty() || selection.size() > 1) { 274 return; 275 } 276 DocumentInfo doc = mModel.getDocument(selection.iterator().next()); 277 Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_SETTINGS); 278 intent.setPackage(mProviders.getPackageName(doc.authority)); 279 intent.addCategory(Intent.CATEGORY_DEFAULT); 280 intent.setData(doc.derivedUri); 281 try { 282 mActivity.startActivity(intent); 283 } catch (ActivityNotFoundException e) { 284 Log.e(TAG, "Failed to view settings in application for " + doc.derivedUri, e); 285 mDialogs.showNoApplicationFound(); 286 } 287 } 288 289 290 @Override 291 public void deleteSelectedDocuments() { 292 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_DELETE); 293 Selection selection = getSelectedOrFocused(); 294 295 if (selection.isEmpty()) { 296 return; 297 } 298 299 final @Nullable DocumentInfo srcParent = mState.stack.peek(); 300 301 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 302 List<DocumentInfo> docs = mModel.getDocuments(selection); 303 304 ConfirmationCallback result = (@Result int code) -> { 305 // share the news with our caller, be it good or bad. 306 mActionModeAddons.finishOnConfirmed(code); 307 308 if (code != ConfirmationCallback.CONFIRM) { 309 return; 310 } 311 312 UrisSupplier srcs; 313 try { 314 srcs = UrisSupplier.create( 315 selection, 316 mModel::getItemUri, 317 mClipStore); 318 } catch (Exception e) { 319 Log.e(TAG,"Failed to delete a file because we were unable to get item URIs.", e); 320 mDialogs.showFileOperationStatus( 321 FileOperations.Callback.STATUS_FAILED, 322 FileOperationService.OPERATION_DELETE, 323 selection.size()); 324 return; 325 } 326 327 FileOperation operation = new FileOperation.Builder() 328 .withOpType(FileOperationService.OPERATION_DELETE) 329 .withDestination(mState.stack) 330 .withSrcs(srcs) 331 .withSrcParent(srcParent == null ? null : srcParent.derivedUri) 332 .build(); 333 334 FileOperations.start(mActivity, operation, mDialogs::showFileOperationStatus, 335 FileOperations.createJobId()); 336 }; 337 338 mDialogs.confirmDelete(docs, result); 339 } 340 341 @Override 342 public void shareSelectedDocuments() { 343 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_SHARE); 344 345 Selection selection = getStableSelection(); 346 347 assert(!selection.isEmpty()); 348 349 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 350 List<DocumentInfo> docs = mModel.loadDocuments( 351 selection, DocumentFilters.sharable(mFeatures)); 352 353 Intent intent; 354 355 if (docs.size() == 1) { 356 intent = new Intent(Intent.ACTION_SEND); 357 DocumentInfo doc = docs.get(0); 358 intent.setType(doc.mimeType); 359 intent.putExtra(Intent.EXTRA_STREAM, doc.derivedUri); 360 361 } else if (docs.size() > 1) { 362 intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 363 364 final ArrayList<String> mimeTypes = new ArrayList<>(); 365 final ArrayList<Uri> uris = new ArrayList<>(); 366 for (DocumentInfo doc : docs) { 367 mimeTypes.add(doc.mimeType); 368 uris.add(doc.derivedUri); 369 } 370 371 intent.setType(MimeTypes.findCommonMimeType(mimeTypes)); 372 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); 373 374 } else { 375 // Everything filtered out, nothing to share. 376 return; 377 } 378 379 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 380 intent.addCategory(Intent.CATEGORY_DEFAULT); 381 382 if (mFeatures.isVirtualFilesSharingEnabled() 383 && mModel.hasDocuments(selection, DocumentFilters.VIRTUAL)) { 384 intent.addCategory(Intent.CATEGORY_TYPED_OPENABLE); 385 } 386 387 Intent chooserIntent = Intent.createChooser( 388 intent, mActivity.getResources().getText(R.string.share_via)); 389 390 mActivity.startActivity(chooserIntent); 391 } 392 393 @Override 394 public void initLocation(Intent intent) { 395 assert(intent != null); 396 397 // stack is initialized if it's restored from bundle, which means we're restoring a 398 // previously stored state. 399 if (mState.stack.isInitialized()) { 400 if (DEBUG) Log.d(TAG, "Stack already resolved for uri: " + intent.getData()); 401 restoreRootAndDirectory(); 402 return; 403 } 404 405 if (launchToStackLocation(intent)) { 406 if (DEBUG) Log.d(TAG, "Launched to location from stack."); 407 return; 408 } 409 410 if (launchToRoot(intent)) { 411 if (DEBUG) Log.d(TAG, "Launched to root for browsing."); 412 return; 413 } 414 415 if (launchToDocument(intent)) { 416 if (DEBUG) Log.d(TAG, "Launched to a document."); 417 return; 418 } 419 420 if (DEBUG) Log.d(TAG, "Launching directly into Home directory."); 421 loadHomeDir(); 422 } 423 424 @Override 425 protected void launchToDefaultLocation() { 426 loadHomeDir(); 427 } 428 429 // If EXTRA_STACK is not null in intent, we'll skip other means of loading 430 // or restoring the stack (like URI). 431 // 432 // When restoring from a stack, if a URI is present, it should only ever be: 433 // -- a launch URI: Launch URIs support sensible activity management, 434 // but don't specify a real content target) 435 // -- a fake Uri from notifications. These URIs have no authority (TODO: details). 436 // 437 // Any other URI is *sorta* unexpected...except when browsing an archive 438 // in downloads. 439 private boolean launchToStackLocation(Intent intent) { 440 DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK); 441 if (stack == null || stack.getRoot() == null) { 442 return false; 443 } 444 445 mState.stack.reset(stack); 446 if (mState.stack.isEmpty()) { 447 mActivity.onRootPicked(mState.stack.getRoot()); 448 } else { 449 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 450 } 451 452 return true; 453 } 454 455 private boolean launchToRoot(Intent intent) { 456 String action = intent.getAction(); 457 if (Intent.ACTION_VIEW.equals(action)) { 458 Uri uri = intent.getData(); 459 if (DocumentsContract.isRootUri(mActivity, uri)) { 460 if (DEBUG) Log.d(TAG, "Launching with root URI."); 461 // If we've got a specific root to display, restore that root using a dedicated 462 // authority. That way a misbehaving provider won't result in an ANR. 463 loadRoot(uri); 464 return true; 465 } 466 } 467 return false; 468 } 469 470 private boolean launchToDocument(Intent intent) { 471 if (Intent.ACTION_VIEW.equals(intent.getAction())) { 472 Uri uri = intent.getData(); 473 if (DocumentsContract.isDocumentUri(mActivity, uri)) { 474 return launchToDocument(intent.getData()); 475 } 476 } 477 478 return false; 479 } 480 481 @Override 482 public void showChooserForDoc(DocumentInfo doc) { 483 assert(!doc.isDirectory()); 484 485 if (manageDocument(doc)) { 486 Log.w(TAG, "Open with is not yet supported for managed doc."); 487 return; 488 } 489 490 Intent intent = Intent.createChooser(buildViewIntent(doc), null); 491 if (Features.OMC_RUNTIME) { 492 intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); 493 } 494 try { 495 mActivity.startActivity(intent); 496 } catch (ActivityNotFoundException e) { 497 mDialogs.showNoApplicationFound(); 498 } 499 } 500 501 private void onDocumentPicked(DocumentInfo doc, @ViewType int type, @ViewType int fallback) { 502 if (doc.isContainer()) { 503 openContainerDocument(doc); 504 return; 505 } 506 507 if (manageDocument(doc)) { 508 return; 509 } 510 511 switch (type) { 512 case VIEW_TYPE_REGULAR: 513 if (viewDocument(doc)) { 514 return; 515 } 516 break; 517 518 case VIEW_TYPE_PREVIEW: 519 if (previewDocument(doc)) { 520 return; 521 } 522 break; 523 524 default: 525 throw new IllegalArgumentException("Illegal view type."); 526 } 527 528 switch (fallback) { 529 case VIEW_TYPE_REGULAR: 530 if (viewDocument(doc)) { 531 return; 532 } 533 break; 534 535 case VIEW_TYPE_PREVIEW: 536 if (previewDocument(doc)) { 537 return; 538 } 539 break; 540 541 case VIEW_TYPE_NONE: 542 break; 543 544 default: 545 throw new IllegalArgumentException("Illegal fallback view type."); 546 } 547 548 // Failed to view including fallback, and it's in an archive. 549 if (type != VIEW_TYPE_NONE && fallback != VIEW_TYPE_NONE && doc.isInArchive()) { 550 mDialogs.showViewInArchivesUnsupported(); 551 } 552 } 553 554 private boolean viewDocument(DocumentInfo doc) { 555 if (doc.isPartial()) { 556 Log.w(TAG, "Can't view partial file."); 557 return false; 558 } 559 560 if (doc.isInArchive()) { 561 Log.w(TAG, "Can't view files in archives."); 562 return false; 563 } 564 565 if (doc.isDirectory()) { 566 Log.w(TAG, "Can't view directories."); 567 return true; 568 } 569 570 Intent intent = buildViewIntent(doc); 571 if (DEBUG && intent.getClipData() != null) { 572 Log.d(TAG, "Starting intent w/ clip data: " + intent.getClipData()); 573 } 574 575 try { 576 mActivity.startActivity(intent); 577 return true; 578 } catch (ActivityNotFoundException e) { 579 mDialogs.showNoApplicationFound(); 580 } 581 return false; 582 } 583 584 private boolean previewDocument(DocumentInfo doc) { 585 if (doc.isPartial()) { 586 Log.w(TAG, "Can't view partial file."); 587 return false; 588 } 589 590 Intent intent = new QuickViewIntentBuilder( 591 mActivity.getPackageManager(), 592 mActivity.getResources(), 593 doc, 594 mModel).build(); 595 596 if (intent != null) { 597 // TODO: un-work around issue b/24963914. Should be fixed soon. 598 try { 599 mActivity.startActivity(intent); 600 return true; 601 } catch (SecurityException e) { 602 // Carry on to regular view mode. 603 Log.e(TAG, "Caught security error: " + e.getLocalizedMessage()); 604 } 605 } 606 607 return false; 608 } 609 610 private boolean manageDocument(DocumentInfo doc) { 611 if (isManagedDownload(doc)) { 612 // First try managing the document; we expect manager to filter 613 // based on authority, so we don't grant. 614 Intent manage = new Intent(DocumentsContract.ACTION_MANAGE_DOCUMENT); 615 manage.setData(doc.derivedUri); 616 try { 617 mActivity.startActivity(manage); 618 return true; 619 } catch (ActivityNotFoundException ex) { 620 // Fall back to regular handling. 621 } 622 } 623 624 return false; 625 } 626 627 private boolean isManagedDownload(DocumentInfo doc) { 628 // Anything on downloads goes through the back through downloads manager 629 // (that's the MANAGE_DOCUMENT bit). 630 // This is done for two reasons: 631 // 1) The file in question might be a failed/queued or otherwise have some 632 // specialized download handling. 633 // 2) For APKs, the download manager will add on some important security stuff 634 // like origin URL. 635 // 3) For partial files, the download manager will offer to restart/retry downloads. 636 637 // All other files not on downloads, event APKs, would get no benefit from this 638 // treatment, thusly the "isDownloads" check. 639 640 // Launch MANAGE_DOCUMENTS only for the root level files, so it's not called for 641 // files in archives. Also, if the activity is already browsing a ZIP from downloads, 642 // then skip MANAGE_DOCUMENTS. 643 if (Intent.ACTION_VIEW.equals(mActivity.getIntent().getAction()) 644 && mState.stack.size() > 1) { 645 // viewing the contents of an archive. 646 return false; 647 } 648 649 // management is only supported in downloads. 650 if (mActivity.getCurrentRoot().isDownloads()) { 651 // and only and only on APKs or partial files. 652 return MimeTypes.isApkType(doc.mimeType) 653 || doc.isPartial(); 654 } 655 656 return false; 657 } 658 659 private Intent buildViewIntent(DocumentInfo doc) { 660 Intent intent = new Intent(Intent.ACTION_VIEW); 661 intent.setDataAndType(doc.derivedUri, doc.mimeType); 662 663 // Downloads has traditionally added the WRITE permission 664 // in the TrampolineActivity. Since this behavior is long 665 // established, we set the same permission for non-managed files 666 // This ensures consistent behavior between the Downloads root 667 // and other roots. 668 int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION; 669 if (doc.isWriteSupported()) { 670 flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; 671 } 672 intent.setFlags(flags); 673 674 return intent; 675 } 676 677 @Override 678 public void showInspector(DocumentInfo doc) { 679 Metrics.logUserAction(mActivity, Metrics.USER_ACTION_INSPECTOR); 680 Intent intent = new Intent(mActivity, InspectorActivity.class); 681 intent.putExtra( 682 Shared.EXTRA_SHOW_DEBUG, 683 mFeatures.isDebugSupportEnabled() 684 || DebugFlags.getDocumentDetailsEnabled()); 685 intent.setData(doc.derivedUri); 686 mActivity.startActivity(intent); 687 } 688 689 public interface Addons extends CommonAddons { 690 } 691 } 692