/* * Copyright (C) 2017 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; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import android.content.ClipData; import android.content.Context; import android.graphics.drawable.Drawable; import android.net.Uri; import android.provider.DocumentsContract; import androidx.annotation.VisibleForTesting; import android.view.DragEvent; import android.view.KeyEvent; import android.view.View; import com.android.documentsui.MenuManager.SelectionDetails; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.DocumentStack; import com.android.documentsui.base.MimeTypes; import com.android.documentsui.base.RootInfo; import com.android.documentsui.clipping.DocumentClipper; import com.android.documentsui.dirlist.IconHelper; import com.android.documentsui.services.FileOperationService; import com.android.documentsui.services.FileOperationService.OpType; import com.android.documentsui.services.FileOperations; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; /** * Manager that tracks control key state, calculates the default file operation (move or copy) * when user drops, and updates drag shadow state. */ public interface DragAndDropManager { @IntDef({ STATE_NOT_ALLOWED, STATE_UNKNOWN, STATE_MOVE, STATE_COPY }) @Retention(RetentionPolicy.SOURCE) @interface State {} int STATE_UNKNOWN = 0; int STATE_NOT_ALLOWED = 1; int STATE_MOVE = 2; int STATE_COPY = 3; /** * Intercepts and handles a {@link KeyEvent}. Used to track the state of Ctrl key state. */ void onKeyEvent(KeyEvent event); /** * Starts a drag and drop. * * @param v the view which * {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} will be * called. * @param srcs documents that are dragged * @param root the root in which documents being dragged are * @param invalidDest destinations that don't accept this drag and drop * @param iconHelper used to load document icons * @param parent {@link DocumentInfo} of the container of srcs */ void startDrag( View v, List srcs, RootInfo root, List invalidDest, SelectionDetails selectionDetails, IconHelper iconHelper, @Nullable DocumentInfo parent); /** * Checks whether the document can be spring opened. * @param root the root in which the document is * @param doc the document to check * @return true if policy allows spring opening it; false otherwise */ boolean canSpringOpen(RootInfo root, DocumentInfo doc); /** * Updates the state to {@link #STATE_NOT_ALLOWED} without any further checks. This is used when * the UI component that handles the drag event already has enough information to disallow * dropping by itself. * * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called. */ void updateStateToNotAllowed(View v); /** * Updates the state according to the destination passed. * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called. * @param destRoot the root of the destination document. * @param destDoc the destination document. Can be null if this is TBD. Must be a folder. * @return the new state. Can be any state in {@link State}. */ @State int updateState( View v, RootInfo destRoot, @Nullable DocumentInfo destDoc); /** * Resets state back to {@link #STATE_UNKNOWN}. This is used when user drags items leaving a UI * component. * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called. */ void resetState(View v); /** * Drops items onto the a root. * * @param clipData the clip data that contains sources information. * @param localState used to determine if this is a multi-window drag and drop. * @param destRoot the target root * @param actions {@link ActionHandler} used to load root document. * @param callback callback called when file operation is rejected or scheduled. * @return true if target accepts this drop; false otherwise */ boolean drop(ClipData clipData, Object localState, RootInfo destRoot, ActionHandler actions, FileOperations.Callback callback); /** * Drops items onto the target. * * @param clipData the clip data that contains sources information. * @param localState used to determine if this is a multi-window drag and drop. * @param dstStack the document stack pointing to the destination folder. * @param callback callback called when file operation is rejected or scheduled. * @return true if target accepts this drop; false otherwise */ boolean drop(ClipData clipData, Object localState, DocumentStack dstStack, FileOperations.Callback callback); /** * Called when drag and drop ended. * * This can be called multiple times as multiple {@link View.OnDragListener} might delegate * {@link DragEvent#ACTION_DRAG_ENDED} events to this class so any work inside needs to be * idempotent. */ void dragEnded(); static DragAndDropManager create(Context context, DocumentClipper clipper) { return new RuntimeDragAndDropManager(context, clipper); } class RuntimeDragAndDropManager implements DragAndDropManager { private static final String SRC_ROOT_KEY = "dragAndDropMgr:srcRoot"; private final Context mContext; private final DocumentClipper mClipper; private final DragShadowBuilder mShadowBuilder; private final Drawable mDefaultShadowIcon; private @State int mState = STATE_UNKNOWN; // Key events info. This is used to derive state when user drags items into a view to derive // type of file operations. private boolean mIsCtrlPressed; // Drag events info. These are used to derive state and update drag shadow when user changes // Ctrl key state. private View mView; private List mInvalidDest; private ClipData mClipData; private RootInfo mDestRoot; private DocumentInfo mDestDoc; // Boolean flag for current drag and drop operation. Returns true if the files can only // be copied (ie. files that don't support delete or remove). private boolean mMustBeCopied; private RuntimeDragAndDropManager(Context context, DocumentClipper clipper) { this( context.getApplicationContext(), clipper, new DragShadowBuilder(context), IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE)); } @VisibleForTesting RuntimeDragAndDropManager(Context context, DocumentClipper clipper, DragShadowBuilder builder, Drawable defaultShadowIcon) { mContext = context; mClipper = clipper; mShadowBuilder = builder; mDefaultShadowIcon = defaultShadowIcon; } @Override public void onKeyEvent(KeyEvent event) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_CTRL_LEFT: case KeyEvent.KEYCODE_CTRL_RIGHT: adjustCtrlKeyCount(event); } } private void adjustCtrlKeyCount(KeyEvent event) { assert(event.getKeyCode() == KeyEvent.KEYCODE_CTRL_LEFT || event.getKeyCode() == KeyEvent.KEYCODE_CTRL_RIGHT); mIsCtrlPressed = event.isCtrlPressed(); // There is an ongoing drag and drop if mView is not null. if (mView != null) { // There is no need to update the state if current state is unknown or not allowed. if (mState == STATE_COPY || mState == STATE_MOVE) { updateState(mView, mDestRoot, mDestDoc); } } } @Override public void startDrag( View v, List srcs, RootInfo root, List invalidDest, SelectionDetails selectionDetails, IconHelper iconHelper, @Nullable DocumentInfo parent) { mView = v; mInvalidDest = invalidDest; mMustBeCopied = !selectionDetails.canDelete(); List uris = new ArrayList<>(srcs.size()); for (DocumentInfo doc : srcs) { uris.add(doc.derivedUri); } mClipData = (parent == null) ? mClipper.getClipDataForDocuments(uris, FileOperationService.OPERATION_UNKNOWN) : mClipper.getClipDataForDocuments( uris, FileOperationService.OPERATION_UNKNOWN, parent); mClipData.getDescription().getExtras() .putString(SRC_ROOT_KEY, root.getUri().toString()); updateShadow(srcs, iconHelper); int flag = View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_OPAQUE; if (!selectionDetails.containsFilesInArchive()) { flag |= View.DRAG_FLAG_GLOBAL_URI_READ | View.DRAG_FLAG_GLOBAL_URI_WRITE; } startDragAndDrop( v, mClipData, mShadowBuilder, this, // Used to detect multi-window drag and drop flag); } private void updateShadow(List srcs, IconHelper iconHelper) { final String title; final Drawable icon; final int size = srcs.size(); if (size == 1) { DocumentInfo doc = srcs.get(0); title = doc.displayName; icon = iconHelper.getDocumentIcon(mContext, doc); } else { title = mContext.getResources() .getQuantityString(R.plurals.elements_dragged, size, size); icon = mDefaultShadowIcon; } mShadowBuilder.updateTitle(title); mShadowBuilder.updateIcon(icon); mShadowBuilder.onStateUpdated(STATE_UNKNOWN); } /** * A workaround of that * {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} is final. */ @VisibleForTesting void startDragAndDrop(View v, ClipData clipData, DragShadowBuilder builder, Object localState, int flags) { v.startDragAndDrop(clipData, builder, localState, flags); } @Override public boolean canSpringOpen(RootInfo root, DocumentInfo doc) { return isValidDestination(root, doc.derivedUri); } @Override public void updateStateToNotAllowed(View v) { mView = v; updateState(STATE_NOT_ALLOWED); } @Override public @State int updateState( View v, RootInfo destRoot, @Nullable DocumentInfo destDoc) { mView = v; mDestRoot = destRoot; mDestDoc = destDoc; if (!destRoot.supportsCreate()) { updateState(STATE_NOT_ALLOWED); return STATE_NOT_ALLOWED; } if (destDoc == null) { updateState(STATE_UNKNOWN); return STATE_UNKNOWN; } assert(destDoc.isDirectory()); if (!destDoc.isCreateSupported() || mInvalidDest.contains(destDoc.derivedUri)) { updateState(STATE_NOT_ALLOWED); return STATE_NOT_ALLOWED; } @State int state; final @OpType int opType = calculateOpType(mClipData, destRoot); switch (opType) { case FileOperationService.OPERATION_COPY: state = STATE_COPY; break; case FileOperationService.OPERATION_MOVE: state = STATE_MOVE; break; default: // Should never happen throw new IllegalStateException("Unknown opType: " + opType); } updateState(state); return state; } @Override public void resetState(View v) { mView = v; updateState(STATE_UNKNOWN); } private void updateState(@State int state) { mState = state; mShadowBuilder.onStateUpdated(state); updateDragShadow(mView); } /** * A workaround of that {@link View#updateDragShadow(View.DragShadowBuilder)} is final. */ @VisibleForTesting void updateDragShadow(View v) { v.updateDragShadow(mShadowBuilder); } @Override public boolean drop(ClipData clipData, Object localState, RootInfo destRoot, ActionHandler action, FileOperations.Callback callback) { final Uri rootDocUri = DocumentsContract.buildDocumentUri(destRoot.authority, destRoot.documentId); if (!isValidDestination(destRoot, rootDocUri)) { return false; } // Calculate the op type now just in case user releases Ctrl key while we're obtaining // root document in the background. final @OpType int opType = calculateOpType(clipData, destRoot); action.getRootDocument( destRoot, TimeoutTask.DEFAULT_TIMEOUT, (DocumentInfo doc) -> { dropOnRootDocument(clipData, localState, destRoot, doc, opType, callback); }); return true; } private void dropOnRootDocument( ClipData clipData, Object localState, RootInfo destRoot, @Nullable DocumentInfo destRootDoc, @OpType int opType, FileOperations.Callback callback) { if (destRootDoc == null) { callback.onOperationResult( FileOperations.Callback.STATUS_FAILED, opType, 0); } else { dropChecked( clipData, localState, new DocumentStack(destRoot, destRootDoc), opType, callback); } } @Override public boolean drop(ClipData clipData, Object localState, DocumentStack dstStack, FileOperations.Callback callback) { if (!canCopyTo(dstStack)) { return false; } dropChecked( clipData, localState, dstStack, calculateOpType(clipData, dstStack.getRoot()), callback); return true; } private void dropChecked(ClipData clipData, Object localState, DocumentStack dstStack, @OpType int opType, FileOperations.Callback callback) { // Recognize multi-window drag and drop based on the fact that localState is not // carried between processes. It will stop working when the localsState behavior // is changed. The info about window should be passed in the localState then. // The localState could also be null for copying from Recents in single window // mode, but Recents doesn't offer this functionality (no directories). Metrics.logUserAction( localState == null ? MetricConsts.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW : MetricConsts.USER_ACTION_DRAG_N_DROP); mClipper.copyFromClipData(dstStack, clipData, opType, callback); } @Override public void dragEnded() { // Multiple drag listeners might delegate drag ended event to this method, so anything // in this method needs to be idempotent. Otherwise we need to designate one listener // that always exists and only let it notify us when drag ended, which will further // complicate code and introduce one more coupling. This is a Android framework // limitation. mView = null; mInvalidDest = null; mClipData = null; mDestDoc = null; mDestRoot = null; mMustBeCopied = false; } private @OpType int calculateOpType(ClipData clipData, RootInfo destRoot) { if (mMustBeCopied) { return FileOperationService.OPERATION_COPY; } final String srcRootUri = clipData.getDescription().getExtras().getString(SRC_ROOT_KEY); final String destRootUri = destRoot.getUri().toString(); assert(srcRootUri != null); assert(destRootUri != null); if (srcRootUri.equals(destRootUri)) { return mIsCtrlPressed ? FileOperationService.OPERATION_COPY : FileOperationService.OPERATION_MOVE; } else { return mIsCtrlPressed ? FileOperationService.OPERATION_MOVE : FileOperationService.OPERATION_COPY; } } private boolean canCopyTo(DocumentStack dstStack) { final RootInfo root = dstStack.getRoot(); final DocumentInfo dst = dstStack.peek(); return isValidDestination(root, dst.derivedUri); } private boolean isValidDestination(RootInfo root, Uri dstUri) { return root.supportsCreate() && !mInvalidDest.contains(dstUri); } } }