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 android.content.ContentResolver.wrap; 20 21 import static com.android.documentsui.base.SharedMinimal.DEBUG; 22 23 import android.app.DownloadManager; 24 import android.content.ActivityNotFoundException; 25 import android.content.ClipData; 26 import android.content.ContentProviderClient; 27 import android.content.ContentResolver; 28 import android.content.Intent; 29 import android.net.Uri; 30 import android.os.FileUtils; 31 import android.provider.DocumentsContract; 32 import android.text.TextUtils; 33 import android.util.Log; 34 import android.view.DragEvent; 35 36 import androidx.annotation.VisibleForTesting; 37 import androidx.fragment.app.FragmentActivity; 38 import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; 39 import androidx.recyclerview.selection.MutableSelection; 40 import androidx.recyclerview.selection.Selection; 41 42 import com.android.documentsui.AbstractActionHandler; 43 import com.android.documentsui.ActionModeAddons; 44 import com.android.documentsui.ActivityConfig; 45 import com.android.documentsui.DocumentsAccess; 46 import com.android.documentsui.DocumentsApplication; 47 import com.android.documentsui.DragAndDropManager; 48 import com.android.documentsui.Injector; 49 import com.android.documentsui.MetricConsts; 50 import com.android.documentsui.Metrics; 51 import com.android.documentsui.R; 52 import com.android.documentsui.TimeoutTask; 53 import com.android.documentsui.base.DebugFlags; 54 import com.android.documentsui.base.DocumentFilters; 55 import com.android.documentsui.base.DocumentInfo; 56 import com.android.documentsui.base.DocumentStack; 57 import com.android.documentsui.base.Features; 58 import com.android.documentsui.base.Lookup; 59 import com.android.documentsui.base.MimeTypes; 60 import com.android.documentsui.base.Providers; 61 import com.android.documentsui.base.RootInfo; 62 import com.android.documentsui.base.Shared; 63 import com.android.documentsui.base.State; 64 import com.android.documentsui.base.UserId; 65 import com.android.documentsui.clipping.ClipStore; 66 import com.android.documentsui.clipping.DocumentClipper; 67 import com.android.documentsui.clipping.UrisSupplier; 68 import com.android.documentsui.dirlist.AnimationView; 69 import com.android.documentsui.inspector.InspectorActivity; 70 import com.android.documentsui.queries.SearchViewManager; 71 import com.android.documentsui.roots.ProvidersAccess; 72 import com.android.documentsui.services.FileOperation; 73 import com.android.documentsui.services.FileOperationService; 74 import com.android.documentsui.services.FileOperations; 75 76 import java.util.ArrayList; 77 import java.util.List; 78 import java.util.concurrent.Executor; 79 80 import javax.annotation.Nullable; 81 82 /** 83 * Provides {@link FilesActivity} action specializations to fragments. 84 * @param <T> activity which extends {@link FragmentActivity} and implements 85 * {@link AbstractActionHandler.CommonAddons}. 86 */ 87 public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.CommonAddons> 88 extends AbstractActionHandler<T> { 89 90 private static final String TAG = "ManagerActionHandler"; 91 private static final int SHARE_FILES_COUNT_LIMIT = 100; 92 93 private final ActionModeAddons mActionModeAddons; 94 private final Features mFeatures; 95 private final ActivityConfig mConfig; 96 private final DocumentClipper mClipper; 97 private final ClipStore mClipStore; 98 private final DragAndDropManager mDragAndDropManager; 99 ActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, ActionModeAddons actionModeAddons, DocumentClipper clipper, ClipStore clipStore, DragAndDropManager dragAndDropManager, Injector injector)100 ActionHandler( 101 T activity, 102 State state, 103 ProvidersAccess providers, 104 DocumentsAccess docs, 105 SearchViewManager searchMgr, 106 Lookup<String, Executor> executors, 107 ActionModeAddons actionModeAddons, 108 DocumentClipper clipper, 109 ClipStore clipStore, 110 DragAndDropManager dragAndDropManager, 111 Injector injector) { 112 113 super(activity, state, providers, docs, searchMgr, executors, injector); 114 115 mActionModeAddons = actionModeAddons; 116 mFeatures = injector.features; 117 mConfig = injector.config; 118 mClipper = clipper; 119 mClipStore = clipStore; 120 mDragAndDropManager = dragAndDropManager; 121 } 122 123 @Override dropOn(DragEvent event, RootInfo root)124 public boolean dropOn(DragEvent event, RootInfo root) { 125 if (!root.supportsCreate() || root.isLibrary()) { 126 return false; 127 } 128 129 // DragEvent gets recycled, so it is possible that by the time the callback is called, 130 // event.getLocalState() and event.getClipData() returns null. Thus, we want to save 131 // references to ensure they are non null. 132 final ClipData clipData = event.getClipData(); 133 final Object localState = event.getLocalState(); 134 135 return mDragAndDropManager.drop( 136 clipData, localState, root, this, mDialogs::showFileOperationStatus); 137 } 138 139 @Override openSelectedInNewWindow()140 public void openSelectedInNewWindow() { 141 Selection<String> selection = getStableSelection(); 142 if (selection.isEmpty()) { 143 return; 144 } 145 146 assert(selection.size() == 1); 147 DocumentInfo doc = mModel.getDocument(selection.iterator().next()); 148 assert(doc != null); 149 openInNewWindow(new DocumentStack(mState.stack, doc)); 150 } 151 152 @Override openSettings(RootInfo root)153 public void openSettings(RootInfo root) { 154 Metrics.logUserAction(MetricConsts.USER_ACTION_SETTINGS); 155 final Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_ROOT_SETTINGS); 156 intent.setDataAndType(root.getUri(), DocumentsContract.Root.MIME_TYPE_ITEM); 157 root.userId.startActivityAsUser(mActivity, intent); 158 } 159 160 @Override pasteIntoFolder(RootInfo root)161 public void pasteIntoFolder(RootInfo root) { 162 this.getRootDocument( 163 root, 164 TimeoutTask.DEFAULT_TIMEOUT, 165 (DocumentInfo doc) -> pasteIntoFolder(root, doc)); 166 } 167 pasteIntoFolder(RootInfo root, @Nullable DocumentInfo doc)168 private void pasteIntoFolder(RootInfo root, @Nullable DocumentInfo doc) { 169 DocumentStack stack = new DocumentStack(root, doc); 170 mClipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationStatus); 171 } 172 173 @Override renameDocument(String name, DocumentInfo document)174 public @Nullable DocumentInfo renameDocument(String name, DocumentInfo document) { 175 ContentResolver resolver = document.userId.getContentResolver(mActivity); 176 ContentProviderClient client = null; 177 178 try { 179 client = DocumentsApplication.acquireUnstableProviderOrThrow( 180 resolver, document.derivedUri.getAuthority()); 181 Uri newUri = DocumentsContract.renameDocument( 182 wrap(client), document.derivedUri, name); 183 return DocumentInfo.fromUri(resolver, newUri, document.userId); 184 } catch (Exception e) { 185 Log.w(TAG, "Failed to rename file", e); 186 return null; 187 } finally { 188 FileUtils.closeQuietly(client); 189 } 190 } 191 192 @Override openRoot(RootInfo root)193 public void openRoot(RootInfo root) { 194 Metrics.logRootVisited(MetricConsts.FILES_SCOPE, root); 195 mActivity.onRootPicked(root); 196 } 197 198 @Override openItem(ItemDetails<String> details, @ViewType int type, @ViewType int fallback)199 public boolean openItem(ItemDetails<String> details, @ViewType int type, 200 @ViewType int fallback) { 201 DocumentInfo doc = mModel.getDocument(details.getSelectionKey()); 202 if (doc == null) { 203 Log.w(TAG, "Can't view item. No Document available for modeId: " 204 + details.getSelectionKey()); 205 return false; 206 } 207 mInjector.searchManager.recordHistory(); 208 209 return openDocument(doc, type, fallback); 210 } 211 212 // TODO: Make this private and make tests call openDocument(DocumentDetails, int, int) instead. 213 @VisibleForTesting openDocument(DocumentInfo doc, @ViewType int type, @ViewType int fallback)214 public boolean openDocument(DocumentInfo doc, @ViewType int type, @ViewType int fallback) { 215 if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) { 216 onDocumentOpened(doc, type, fallback, false); 217 mSelectionMgr.clearSelection(); 218 return !doc.isContainer(); 219 } 220 return false; 221 } 222 223 @Override springOpenDirectory(DocumentInfo doc)224 public void springOpenDirectory(DocumentInfo doc) { 225 assert(doc.isDirectory()); 226 mActionModeAddons.finishActionMode(); 227 openContainerDocument(doc); 228 } 229 getSelectedOrFocused()230 private Selection<String> getSelectedOrFocused() { 231 final MutableSelection<String> selection = this.getStableSelection(); 232 if (selection.isEmpty()) { 233 String focusModelId = mFocusHandler.getFocusModelId(); 234 if (focusModelId != null) { 235 selection.add(focusModelId); 236 } 237 } 238 239 return selection; 240 } 241 242 @Override cutToClipboard()243 public void cutToClipboard() { 244 Metrics.logUserAction(MetricConsts.USER_ACTION_CUT_CLIPBOARD); 245 Selection<String> selection = getSelectedOrFocused(); 246 247 if (selection.isEmpty()) { 248 return; 249 } 250 251 if (mModel.hasDocuments(selection, DocumentFilters.NOT_MOVABLE)) { 252 mDialogs.showOperationUnsupported(); 253 return; 254 } 255 256 mSelectionMgr.clearSelection(); 257 258 mClipper.clipDocumentsForCut(mModel::getItemUri, selection, mState.stack.peek()); 259 260 mDialogs.showDocumentsClipped(selection.size()); 261 } 262 263 @Override copyToClipboard()264 public void copyToClipboard() { 265 Metrics.logUserAction(MetricConsts.USER_ACTION_COPY_CLIPBOARD); 266 Selection<String> selection = getSelectedOrFocused(); 267 268 if (selection.isEmpty()) { 269 return; 270 } 271 mSelectionMgr.clearSelection(); 272 273 mClipper.clipDocumentsForCopy(mModel::getItemUri, selection); 274 275 mDialogs.showDocumentsClipped(selection.size()); 276 } 277 278 @Override viewInOwner()279 public void viewInOwner() { 280 Metrics.logUserAction(MetricConsts.USER_ACTION_VIEW_IN_APPLICATION); 281 Selection<String> selection = getSelectedOrFocused(); 282 283 if (selection.isEmpty() || selection.size() > 1) { 284 return; 285 } 286 DocumentInfo doc = mModel.getDocument(selection.iterator().next()); 287 Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_SETTINGS); 288 intent.setPackage(mProviders.getPackageName(UserId.DEFAULT_USER, doc.authority)); 289 intent.addCategory(Intent.CATEGORY_DEFAULT); 290 intent.setData(doc.derivedUri); 291 try { 292 doc.userId.startActivityAsUser(mActivity, intent); 293 } catch (ActivityNotFoundException e) { 294 Log.e(TAG, "Failed to view settings in application for " + doc.derivedUri, e); 295 mDialogs.showNoApplicationFound(); 296 } 297 } 298 299 @Override showDeleteDialog()300 public void showDeleteDialog() { 301 Selection selection = getSelectedOrFocused(); 302 if (selection.isEmpty()) { 303 return; 304 } 305 306 DeleteDocumentFragment.show(mActivity.getSupportFragmentManager(), 307 mModel.getDocuments(selection), 308 mState.stack.peek()); 309 } 310 311 312 @Override deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent)313 public void deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent) { 314 if (docs == null || docs.isEmpty()) { 315 return; 316 } 317 318 mActionModeAddons.finishActionMode(); 319 320 List<Uri> uris = new ArrayList<>(docs.size()); 321 for (DocumentInfo doc : docs) { 322 uris.add(doc.derivedUri); 323 } 324 325 UrisSupplier srcs; 326 try { 327 srcs = UrisSupplier.create( 328 uris, 329 mClipStore); 330 } catch (Exception e) { 331 Log.e(TAG, "Failed to delete a file because we were unable to get item URIs.", e); 332 mDialogs.showFileOperationStatus( 333 FileOperations.Callback.STATUS_FAILED, 334 FileOperationService.OPERATION_DELETE, 335 uris.size()); 336 return; 337 } 338 339 FileOperation operation = new FileOperation.Builder() 340 .withOpType(FileOperationService.OPERATION_DELETE) 341 .withDestination(mState.stack) 342 .withSrcs(srcs) 343 .withSrcParent(srcParent == null ? null : srcParent.derivedUri) 344 .build(); 345 346 FileOperations.start(mActivity, operation, mDialogs::showFileOperationStatus, 347 FileOperations.createJobId()); 348 } 349 350 @Override shareSelectedDocuments()351 public void shareSelectedDocuments() { 352 Metrics.logUserAction(MetricConsts.USER_ACTION_SHARE); 353 354 Selection<String> selection = getStableSelection(); 355 if (selection.isEmpty()) { 356 return; 357 } else if (selection.size() > SHARE_FILES_COUNT_LIMIT) { 358 mDialogs.showShareOverLimit(SHARE_FILES_COUNT_LIMIT); 359 return; 360 } 361 362 // Model must be accessed in UI thread, since underlying cursor is not threadsafe. 363 List<DocumentInfo> docs = mModel.loadDocuments( 364 selection, DocumentFilters.sharable(mFeatures)); 365 366 Intent intent; 367 368 if (docs.size() == 1) { 369 intent = new Intent(Intent.ACTION_SEND); 370 DocumentInfo doc = docs.get(0); 371 intent.setType(doc.mimeType); 372 intent.putExtra(Intent.EXTRA_STREAM, doc.getDocumentUri()); 373 374 } else if (docs.size() > 1) { 375 intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 376 377 final ArrayList<String> mimeTypes = new ArrayList<>(); 378 final ArrayList<Uri> uris = new ArrayList<>(); 379 for (DocumentInfo doc : docs) { 380 mimeTypes.add(doc.mimeType); 381 uris.add(doc.getDocumentUri()); 382 } 383 384 intent.setType(MimeTypes.findCommonMimeType(mimeTypes)); 385 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); 386 387 } else { 388 // Everything filtered out, nothing to share. 389 return; 390 } 391 392 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 393 intent.addCategory(Intent.CATEGORY_DEFAULT); 394 395 if (mFeatures.isVirtualFilesSharingEnabled() 396 && mModel.hasDocuments(selection, DocumentFilters.VIRTUAL)) { 397 intent.addCategory(Intent.CATEGORY_TYPED_OPENABLE); 398 } 399 400 Intent chooserIntent = Intent.createChooser( 401 intent, mActivity.getResources().getText(R.string.share_via)); 402 403 mActivity.startActivity(chooserIntent); 404 } 405 406 @Override loadDocumentsForCurrentStack()407 public void loadDocumentsForCurrentStack() { 408 super.loadDocumentsForCurrentStack(); 409 } 410 411 @Override initLocation(Intent intent)412 public void initLocation(Intent intent) { 413 assert(intent != null); 414 415 // stack is initialized if it's restored from bundle, which means we're restoring a 416 // previously stored state. 417 if (mState.stack.isInitialized()) { 418 if (DEBUG) { 419 Log.d(TAG, "Stack already resolved for uri: " + intent.getData()); 420 } 421 restoreRootAndDirectory(); 422 return; 423 } 424 425 if (launchToStackLocation(intent)) { 426 if (DEBUG) { 427 Log.d(TAG, "Launched to location from stack."); 428 } 429 return; 430 } 431 432 if (launchToRoot(intent)) { 433 if (DEBUG) { 434 Log.d(TAG, "Launched to root for browsing."); 435 } 436 return; 437 } 438 439 if (launchToDocument(intent)) { 440 if (DEBUG) { 441 Log.d(TAG, "Launched to a document."); 442 } 443 return; 444 } 445 446 if (launchToDownloads(intent)) { 447 if (DEBUG) { 448 Log.d(TAG, "Launched to a downloads."); 449 } 450 return; 451 } 452 453 if (DEBUG) { 454 Log.d(TAG, "Launching directly into Home directory."); 455 } 456 launchToDefaultLocation(); 457 } 458 459 @Override launchToDefaultLocation()460 protected void launchToDefaultLocation() { 461 loadHomeDir(); 462 } 463 464 // If EXTRA_STACK is not null in intent, we'll skip other means of loading 465 // or restoring the stack (like URI). 466 // 467 // When restoring from a stack, if a URI is present, it should only ever be: 468 // -- a launch URI: Launch URIs support sensible activity management, 469 // but don't specify a real content target) 470 // -- a fake Uri from notifications. These URIs have no authority (TODO: details). 471 // 472 // Any other URI is *sorta* unexpected...except when browsing an archive 473 // in downloads. launchToStackLocation(Intent intent)474 private boolean launchToStackLocation(Intent intent) { 475 DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK); 476 if (stack == null || stack.getRoot() == null) { 477 return false; 478 } 479 480 mState.stack.reset(stack); 481 if (mState.stack.isEmpty()) { 482 mActivity.onRootPicked(mState.stack.getRoot()); 483 } else { 484 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 485 } 486 487 return true; 488 } 489 launchToRoot(Intent intent)490 private boolean launchToRoot(Intent intent) { 491 String action = intent.getAction(); 492 if (Intent.ACTION_VIEW.equals(action)) { 493 Uri uri = intent.getData(); 494 if (DocumentsContract.isRootUri(mActivity, uri)) { 495 if (DEBUG) { 496 Log.d(TAG, "Launching with root URI."); 497 } 498 // If we've got a specific root to display, restore that root using a dedicated 499 // authority. That way a misbehaving provider won't result in an ANR. 500 loadRoot(uri, UserId.DEFAULT_USER); 501 return true; 502 } else if (DocumentsContract.isRootsUri(mActivity, uri)) { 503 if (DEBUG) { 504 Log.d(TAG, "Launching first root with roots URI."); 505 } 506 // TODO: b/116760996 Let the user can disambiguate between roots if there are 507 // multiple from DocumentsProvider instead of launching the first root in default 508 loadFirstRoot(uri); 509 return true; 510 } 511 } 512 return false; 513 } 514 launchToDocument(Intent intent)515 private boolean launchToDocument(Intent intent) { 516 if (Intent.ACTION_VIEW.equals(intent.getAction())) { 517 Uri uri = intent.getData(); 518 if (DocumentsContract.isDocumentUri(mActivity, uri)) { 519 return launchToDocument(intent.getData()); 520 } 521 } 522 523 return false; 524 } 525 launchToDownloads(Intent intent)526 private boolean launchToDownloads(Intent intent) { 527 if (DownloadManager.ACTION_VIEW_DOWNLOADS.equals(intent.getAction())) { 528 Uri uri = DocumentsContract.buildRootUri(Providers.AUTHORITY_DOWNLOADS, 529 Providers.ROOT_ID_DOWNLOADS); 530 loadRoot(uri, UserId.DEFAULT_USER); 531 return true; 532 } 533 534 return false; 535 } 536 537 @Override showChooserForDoc(DocumentInfo doc)538 public void showChooserForDoc(DocumentInfo doc) { 539 assert(!doc.isDirectory()); 540 541 if (manageDocument(doc)) { 542 Log.w(TAG, "Open with is not yet supported for managed doc."); 543 return; 544 } 545 546 Intent intent = Intent.createChooser(buildViewIntent(doc), null); 547 intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); 548 try { 549 doc.userId.startActivityAsUser(mActivity, intent); 550 } catch (ActivityNotFoundException e) { 551 mDialogs.showNoApplicationFound(); 552 } 553 } 554 555 @Override showInspector(DocumentInfo doc)556 public void showInspector(DocumentInfo doc) { 557 Metrics.logUserAction(MetricConsts.USER_ACTION_INSPECTOR); 558 Intent intent = InspectorActivity.createIntent(mActivity, doc.derivedUri, doc.userId); 559 560 // permit the display of debug info about the file. 561 intent.putExtra( 562 Shared.EXTRA_SHOW_DEBUG, 563 mFeatures.isDebugSupportEnabled() && 564 (DEBUG || DebugFlags.getDocumentDetailsEnabled())); 565 566 // The "root document" (top level folder in a root) don't usually have a 567 // human friendly display name. That's because we've never shown the root 568 // folder's name to anyone. 569 // For that reason when the doc being inspected is the root folder, 570 // we override the displayName of the doc w/ the Root's name instead. 571 // The Root's name is shown to the user in the sidebar. 572 if (doc.isDirectory() && mState.stack.size() == 1 && mState.stack.get(0).equals(doc)) { 573 RootInfo root = mActivity.getCurrentRoot(); 574 // Recents root title isn't defined, but inspector is disabled for recents root folder. 575 assert !TextUtils.isEmpty(root.title); 576 intent.putExtra(Intent.EXTRA_TITLE, root.title); 577 } 578 mActivity.startActivity(intent); 579 } 580 } 581