blob: 20b83185658cab609a2c907e367a0b1bac6983b4 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.documentsui.files;
import static android.content.ContentResolver.wrap;
import static com.android.documentsui.base.SharedMinimal.DEBUG;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Intent;
import android.net.Uri;
import android.os.FileUtils;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import android.view.DragEvent;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import androidx.recyclerview.selection.MutableSelection;
import androidx.recyclerview.selection.Selection;
import com.android.documentsui.AbstractActionHandler;
import com.android.documentsui.ActionModeAddons;
import com.android.documentsui.ActivityConfig;
import com.android.documentsui.DocumentsAccess;
import com.android.documentsui.DocumentsApplication;
import com.android.documentsui.DragAndDropManager;
import com.android.documentsui.Injector;
import com.android.documentsui.MetricConsts;
import com.android.documentsui.Metrics;
import com.android.documentsui.R;
import com.android.documentsui.TimeoutTask;
import com.android.documentsui.base.DebugFlags;
import com.android.documentsui.base.DocumentFilters;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.DocumentStack;
import com.android.documentsui.base.Features;
import com.android.documentsui.base.Lookup;
import com.android.documentsui.base.MimeTypes;
import com.android.documentsui.base.Providers;
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.base.Shared;
import com.android.documentsui.base.State;
import com.android.documentsui.base.UserId;
import com.android.documentsui.clipping.ClipStore;
import com.android.documentsui.clipping.DocumentClipper;
import com.android.documentsui.clipping.UrisSupplier;
import com.android.documentsui.dirlist.AnimationView;
import com.android.documentsui.inspector.InspectorActivity;
import com.android.documentsui.queries.SearchViewManager;
import com.android.documentsui.roots.ProvidersAccess;
import com.android.documentsui.services.FileOperation;
import com.android.documentsui.services.FileOperationService;
import com.android.documentsui.services.FileOperations;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import javax.annotation.Nullable;
/**
* Provides {@link FilesActivity} action specializations to fragments.
* @param <T> activity which extends {@link FragmentActivity} and implements
* {@link AbstractActionHandler.CommonAddons}.
*/
public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.CommonAddons>
extends AbstractActionHandler<T> {
private static final String TAG = "ManagerActionHandler";
private static final int SHARE_FILES_COUNT_LIMIT = 100;
private final ActionModeAddons mActionModeAddons;
private final Features mFeatures;
private final ActivityConfig mConfig;
private final DocumentClipper mClipper;
private final ClipStore mClipStore;
private final DragAndDropManager mDragAndDropManager;
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) {
super(activity, state, providers, docs, searchMgr, executors, injector);
mActionModeAddons = actionModeAddons;
mFeatures = injector.features;
mConfig = injector.config;
mClipper = clipper;
mClipStore = clipStore;
mDragAndDropManager = dragAndDropManager;
}
@Override
public boolean dropOn(DragEvent event, RootInfo root) {
if (!root.supportsCreate() || root.isLibrary()) {
return false;
}
// DragEvent gets recycled, so it is possible that by the time the callback is called,
// event.getLocalState() and event.getClipData() returns null. Thus, we want to save
// references to ensure they are non null.
final ClipData clipData = event.getClipData();
final Object localState = event.getLocalState();
return mDragAndDropManager.drop(
clipData, localState, root, this, mDialogs::showFileOperationStatus);
}
@Override
public void openSelectedInNewWindow() {
Selection<String> selection = getStableSelection();
if (selection.isEmpty()) {
return;
}
assert(selection.size() == 1);
DocumentInfo doc = mModel.getDocument(selection.iterator().next());
assert(doc != null);
openInNewWindow(new DocumentStack(mState.stack, doc));
}
@Override
public void openSettings(RootInfo root) {
Metrics.logUserAction(MetricConsts.USER_ACTION_SETTINGS);
final Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_ROOT_SETTINGS);
intent.setDataAndType(root.getUri(), DocumentsContract.Root.MIME_TYPE_ITEM);
root.userId.startActivityAsUser(mActivity, intent);
}
@Override
public void pasteIntoFolder(RootInfo root) {
this.getRootDocument(
root,
TimeoutTask.DEFAULT_TIMEOUT,
(DocumentInfo doc) -> pasteIntoFolder(root, doc));
}
private void pasteIntoFolder(RootInfo root, @Nullable DocumentInfo doc) {
DocumentStack stack = new DocumentStack(root, doc);
mClipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationStatus);
}
@Override
public @Nullable DocumentInfo renameDocument(String name, DocumentInfo document) {
ContentResolver resolver = document.userId.getContentResolver(mActivity);
ContentProviderClient client = null;
try {
client = DocumentsApplication.acquireUnstableProviderOrThrow(
resolver, document.derivedUri.getAuthority());
Uri newUri = DocumentsContract.renameDocument(
wrap(client), document.derivedUri, name);
return DocumentInfo.fromUri(resolver, newUri, document.userId);
} catch (Exception e) {
Log.w(TAG, "Failed to rename file", e);
return null;
} finally {
FileUtils.closeQuietly(client);
}
}
@Override
public void openRoot(RootInfo root) {
Metrics.logRootVisited(MetricConsts.FILES_SCOPE, root);
mActivity.onRootPicked(root);
}
@Override
public boolean openItem(ItemDetails<String> details, @ViewType int type,
@ViewType int fallback) {
DocumentInfo doc = mModel.getDocument(details.getSelectionKey());
if (doc == null) {
Log.w(TAG, "Can't view item. No Document available for modeId: "
+ details.getSelectionKey());
return false;
}
mInjector.searchManager.recordHistory();
return openDocument(doc, type, fallback);
}
// TODO: Make this private and make tests call openDocument(DocumentDetails, int, int) instead.
@VisibleForTesting
public boolean openDocument(DocumentInfo doc, @ViewType int type, @ViewType int fallback) {
if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) {
onDocumentOpened(doc, type, fallback, false);
mSelectionMgr.clearSelection();
return !doc.isContainer();
}
return false;
}
@Override
public void springOpenDirectory(DocumentInfo doc) {
assert(doc.isDirectory());
mActionModeAddons.finishActionMode();
openContainerDocument(doc);
}
private Selection<String> getSelectedOrFocused() {
final MutableSelection<String> selection = this.getStableSelection();
if (selection.isEmpty()) {
String focusModelId = mFocusHandler.getFocusModelId();
if (focusModelId != null) {
selection.add(focusModelId);
}
}
return selection;
}
@Override
public void cutToClipboard() {
Metrics.logUserAction(MetricConsts.USER_ACTION_CUT_CLIPBOARD);
Selection<String> selection = getSelectedOrFocused();
if (selection.isEmpty()) {
return;
}
if (mModel.hasDocuments(selection, DocumentFilters.NOT_MOVABLE)) {
mDialogs.showOperationUnsupported();
return;
}
mSelectionMgr.clearSelection();
mClipper.clipDocumentsForCut(mModel::getItemUri, selection, mState.stack.peek());
mDialogs.showDocumentsClipped(selection.size());
}
@Override
public void copyToClipboard() {
Metrics.logUserAction(MetricConsts.USER_ACTION_COPY_CLIPBOARD);
Selection<String> selection = getSelectedOrFocused();
if (selection.isEmpty()) {
return;
}
mSelectionMgr.clearSelection();
mClipper.clipDocumentsForCopy(mModel::getItemUri, selection);
mDialogs.showDocumentsClipped(selection.size());
}
@Override
public void viewInOwner() {
Metrics.logUserAction(MetricConsts.USER_ACTION_VIEW_IN_APPLICATION);
Selection<String> selection = getSelectedOrFocused();
if (selection.isEmpty() || selection.size() > 1) {
return;
}
DocumentInfo doc = mModel.getDocument(selection.iterator().next());
Intent intent = new Intent(DocumentsContract.ACTION_DOCUMENT_SETTINGS);
intent.setPackage(mProviders.getPackageName(UserId.DEFAULT_USER, doc.authority));
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.setData(doc.derivedUri);
try {
doc.userId.startActivityAsUser(mActivity, intent);
} catch (ActivityNotFoundException e) {
Log.e(TAG, "Failed to view settings in application for " + doc.derivedUri, e);
mDialogs.showNoApplicationFound();
}
}
@Override
public void showDeleteDialog() {
Selection selection = getSelectedOrFocused();
if (selection.isEmpty()) {
return;
}
DeleteDocumentFragment.show(mActivity.getSupportFragmentManager(),
mModel.getDocuments(selection),
mState.stack.peek());
}
@Override
public void deleteSelectedDocuments(List<DocumentInfo> docs, DocumentInfo srcParent) {
if (docs == null || docs.isEmpty()) {
return;
}
mActionModeAddons.finishActionMode();
List<Uri> uris = new ArrayList<>(docs.size());
for (DocumentInfo doc : docs) {
uris.add(doc.derivedUri);
}
UrisSupplier srcs;
try {
srcs = UrisSupplier.create(
uris,
mClipStore);
} catch (Exception e) {
Log.e(TAG, "Failed to delete a file because we were unable to get item URIs.", e);
mDialogs.showFileOperationStatus(
FileOperations.Callback.STATUS_FAILED,
FileOperationService.OPERATION_DELETE,
uris.size());
return;
}
FileOperation operation = new FileOperation.Builder()
.withOpType(FileOperationService.OPERATION_DELETE)
.withDestination(mState.stack)
.withSrcs(srcs)
.withSrcParent(srcParent == null ? null : srcParent.derivedUri)
.build();
FileOperations.start(mActivity, operation, mDialogs::showFileOperationStatus,
FileOperations.createJobId());
}
@Override
public void shareSelectedDocuments() {
Metrics.logUserAction(MetricConsts.USER_ACTION_SHARE);
Selection<String> selection = getStableSelection();
if (selection.isEmpty()) {
return;
} else if (selection.size() > SHARE_FILES_COUNT_LIMIT) {
mDialogs.showShareOverLimit(SHARE_FILES_COUNT_LIMIT);
return;
}
// Model must be accessed in UI thread, since underlying cursor is not threadsafe.
List<DocumentInfo> docs = mModel.loadDocuments(
selection, DocumentFilters.sharable(mFeatures));
Intent intent;
if (docs.size() == 1) {
intent = new Intent(Intent.ACTION_SEND);
DocumentInfo doc = docs.get(0);
intent.setType(doc.mimeType);
intent.putExtra(Intent.EXTRA_STREAM, doc.getDocumentUri());
} else if (docs.size() > 1) {
intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
final ArrayList<String> mimeTypes = new ArrayList<>();
final ArrayList<Uri> uris = new ArrayList<>();
for (DocumentInfo doc : docs) {
mimeTypes.add(doc.mimeType);
uris.add(doc.getDocumentUri());
}
intent.setType(MimeTypes.findCommonMimeType(mimeTypes));
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
} else {
// Everything filtered out, nothing to share.
return;
}
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_DEFAULT);
if (mFeatures.isVirtualFilesSharingEnabled()
&& mModel.hasDocuments(selection, DocumentFilters.VIRTUAL)) {
intent.addCategory(Intent.CATEGORY_TYPED_OPENABLE);
}
Intent chooserIntent = Intent.createChooser(
intent, mActivity.getResources().getText(R.string.share_via));
mActivity.startActivity(chooserIntent);
}
@Override
public void loadDocumentsForCurrentStack() {
super.loadDocumentsForCurrentStack();
}
@Override
public void initLocation(Intent intent) {
assert(intent != null);
// stack is initialized if it's restored from bundle, which means we're restoring a
// previously stored state.
if (mState.stack.isInitialized()) {
if (DEBUG) {
Log.d(TAG, "Stack already resolved for uri: " + intent.getData());
}
restoreRootAndDirectory();
return;
}
if (launchToStackLocation(intent)) {
if (DEBUG) {
Log.d(TAG, "Launched to location from stack.");
}
return;
}
if (launchToRoot(intent)) {
if (DEBUG) {
Log.d(TAG, "Launched to root for browsing.");
}
return;
}
if (launchToDocument(intent)) {
if (DEBUG) {
Log.d(TAG, "Launched to a document.");
}
return;
}
if (launchToDownloads(intent)) {
if (DEBUG) {
Log.d(TAG, "Launched to a downloads.");
}
return;
}
if (DEBUG) {
Log.d(TAG, "Launching directly into Home directory.");
}
launchToDefaultLocation();
}
@Override
protected void launchToDefaultLocation() {
loadHomeDir();
}
// If EXTRA_STACK is not null in intent, we'll skip other means of loading
// or restoring the stack (like URI).
//
// When restoring from a stack, if a URI is present, it should only ever be:
// -- a launch URI: Launch URIs support sensible activity management,
// but don't specify a real content target)
// -- a fake Uri from notifications. These URIs have no authority (TODO: details).
//
// Any other URI is *sorta* unexpected...except when browsing an archive
// in downloads.
private boolean launchToStackLocation(Intent intent) {
DocumentStack stack = intent.getParcelableExtra(Shared.EXTRA_STACK);
if (stack == null || stack.getRoot() == null) {
return false;
}
mState.stack.reset(stack);
if (mState.stack.isEmpty()) {
mActivity.onRootPicked(mState.stack.getRoot());
} else {
mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
}
return true;
}
private boolean launchToRoot(Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_VIEW.equals(action)) {
Uri uri = intent.getData();
if (DocumentsContract.isRootUri(mActivity, uri)) {
if (DEBUG) {
Log.d(TAG, "Launching with root URI.");
}
// If we've got a specific root to display, restore that root using a dedicated
// authority. That way a misbehaving provider won't result in an ANR.
loadRoot(uri, UserId.DEFAULT_USER);
return true;
} else if (DocumentsContract.isRootsUri(mActivity, uri)) {
if (DEBUG) {
Log.d(TAG, "Launching first root with roots URI.");
}
// TODO: b/116760996 Let the user can disambiguate between roots if there are
// multiple from DocumentsProvider instead of launching the first root in default
loadFirstRoot(uri);
return true;
}
}
return false;
}
private boolean launchToDocument(Intent intent) {
if (Intent.ACTION_VIEW.equals(intent.getAction())) {
Uri uri = intent.getData();
if (DocumentsContract.isDocumentUri(mActivity, uri)) {
return launchToDocument(intent.getData());
}
}
return false;
}
private boolean launchToDownloads(Intent intent) {
if (DownloadManager.ACTION_VIEW_DOWNLOADS.equals(intent.getAction())) {
Uri uri = DocumentsContract.buildRootUri(Providers.AUTHORITY_DOWNLOADS,
Providers.ROOT_ID_DOWNLOADS);
loadRoot(uri, UserId.DEFAULT_USER);
return true;
}
return false;
}
@Override
public void showChooserForDoc(DocumentInfo doc) {
assert(!doc.isDirectory());
if (manageDocument(doc)) {
Log.w(TAG, "Open with is not yet supported for managed doc.");
return;
}
Intent intent = Intent.createChooser(buildViewIntent(doc), null);
intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false);
try {
doc.userId.startActivityAsUser(mActivity, intent);
} catch (ActivityNotFoundException e) {
mDialogs.showNoApplicationFound();
}
}
@Override
public void showInspector(DocumentInfo doc) {
Metrics.logUserAction(MetricConsts.USER_ACTION_INSPECTOR);
Intent intent = InspectorActivity.createIntent(mActivity, doc.derivedUri, doc.userId);
// permit the display of debug info about the file.
intent.putExtra(
Shared.EXTRA_SHOW_DEBUG,
mFeatures.isDebugSupportEnabled() &&
(DEBUG || DebugFlags.getDocumentDetailsEnabled()));
// The "root document" (top level folder in a root) don't usually have a
// human friendly display name. That's because we've never shown the root
// folder's name to anyone.
// For that reason when the doc being inspected is the root folder,
// we override the displayName of the doc w/ the Root's name instead.
// The Root's name is shown to the user in the sidebar.
if (doc.isDirectory() && mState.stack.size() == 1 && mState.stack.get(0).equals(doc)) {
RootInfo root = mActivity.getCurrentRoot();
// Recents root title isn't defined, but inspector is disabled for recents root folder.
assert !TextUtils.isEmpty(root.title);
intent.putExtra(Intent.EXTRA_TITLE, root.title);
}
mActivity.startActivity(intent);
}
}