Home | History | Annotate | Download | only in documentsui
      1 /*
      2  * Copyright (C) 2017 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 android.annotation.IntDef;
     20 import android.annotation.Nullable;
     21 import android.content.ClipData;
     22 import android.content.Context;
     23 import android.graphics.drawable.Drawable;
     24 import android.net.Uri;
     25 import android.provider.DocumentsContract;
     26 import android.support.annotation.VisibleForTesting;
     27 import android.view.DragEvent;
     28 import android.view.KeyEvent;
     29 import android.view.View;
     30 
     31 import com.android.documentsui.MenuManager.SelectionDetails;
     32 import com.android.documentsui.base.DocumentInfo;
     33 import com.android.documentsui.base.DocumentStack;
     34 import com.android.documentsui.base.RootInfo;
     35 import com.android.documentsui.clipping.DocumentClipper;
     36 import com.android.documentsui.dirlist.IconHelper;
     37 import com.android.documentsui.services.FileOperationService;
     38 import com.android.documentsui.services.FileOperationService.OpType;
     39 import com.android.documentsui.services.FileOperations;
     40 
     41 import java.lang.annotation.Retention;
     42 import java.lang.annotation.RetentionPolicy;
     43 import java.util.ArrayList;
     44 import java.util.List;
     45 
     46 /**
     47  * Manager that tracks control key state, calculates the default file operation (move or copy)
     48  * when user drops, and updates drag shadow state.
     49  */
     50 public interface DragAndDropManager {
     51 
     52     @IntDef({ STATE_NOT_ALLOWED, STATE_UNKNOWN, STATE_MOVE, STATE_COPY })
     53     @Retention(RetentionPolicy.SOURCE)
     54     @interface State {}
     55     int STATE_UNKNOWN = 0;
     56     int STATE_NOT_ALLOWED = 1;
     57     int STATE_MOVE = 2;
     58     int STATE_COPY = 3;
     59 
     60     /**
     61      * Intercepts and handles a {@link KeyEvent}. Used to track the state of Ctrl key state.
     62      */
     63     void onKeyEvent(KeyEvent event);
     64 
     65     /**
     66      * Starts a drag and drop.
     67      *
     68      * @param v the view which
     69      *          {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} will be
     70      *          called.
     71      * @param srcs documents that are dragged
     72      * @param root the root in which documents being dragged are
     73      * @param invalidDest destinations that don't accept this drag and drop
     74      * @param iconHelper used to load document icons
     75      * @param parent {@link DocumentInfo} of the container of srcs
     76      */
     77     void startDrag(
     78             View v,
     79             List<DocumentInfo> srcs,
     80             RootInfo root,
     81             List<Uri> invalidDest,
     82             SelectionDetails selectionDetails,
     83             IconHelper iconHelper,
     84             @Nullable DocumentInfo parent);
     85 
     86     /**
     87      * Checks whether the document can be spring opened.
     88      * @param root the root in which the document is
     89      * @param doc the document to check
     90      * @return true if policy allows spring opening it; false otherwise
     91      */
     92     boolean canSpringOpen(RootInfo root, DocumentInfo doc);
     93 
     94     /**
     95      * Updates the state to {@link #STATE_NOT_ALLOWED} without any further checks. This is used when
     96      * the UI component that handles the drag event already has enough information to disallow
     97      * dropping by itself.
     98      *
     99      * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
    100      */
    101     void updateStateToNotAllowed(View v);
    102 
    103     /**
    104      * Updates the state according to the destination passed.
    105      * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
    106      * @param destRoot the root of the destination document.
    107      * @param destDoc the destination document. Can be null if this is TBD. Must be a folder.
    108      * @return the new state. Can be any state in {@link State}.
    109      */
    110     @State int updateState(
    111             View v, RootInfo destRoot, @Nullable DocumentInfo destDoc);
    112 
    113     /**
    114      * Resets state back to {@link #STATE_UNKNOWN}. This is used when user drags items leaving a UI
    115      * component.
    116      * @param v the view which {@link View#updateDragShadow(View.DragShadowBuilder)} will be called.
    117      */
    118     void resetState(View v);
    119 
    120     /**
    121      * Drops items onto the a root.
    122      *
    123      * @param clipData the clip data that contains sources information.
    124      * @param localState used to determine if this is a multi-window drag and drop.
    125      * @param destRoot the target root
    126      * @param actions {@link ActionHandler} used to load root document.
    127      * @param callback callback called when file operation is rejected or scheduled.
    128      * @return true if target accepts this drop; false otherwise
    129      */
    130     boolean drop(ClipData clipData, Object localState, RootInfo destRoot, ActionHandler actions,
    131             FileOperations.Callback callback);
    132 
    133     /**
    134      * Drops items onto the target.
    135      *
    136      * @param clipData the clip data that contains sources information.
    137      * @param localState used to determine if this is a multi-window drag and drop.
    138      * @param dstStack the document stack pointing to the destination folder.
    139      * @param callback callback called when file operation is rejected or scheduled.
    140      * @return true if target accepts this drop; false otherwise
    141      */
    142     boolean drop(ClipData clipData, Object localState, DocumentStack dstStack,
    143             FileOperations.Callback callback);
    144 
    145     /**
    146      * Called when drag and drop ended.
    147      *
    148      * This can be called multiple times as multiple {@link View.OnDragListener} might delegate
    149      * {@link DragEvent#ACTION_DRAG_ENDED} events to this class so any work inside needs to be
    150      * idempotent.
    151      */
    152     void dragEnded();
    153 
    154     static DragAndDropManager create(Context context, DocumentClipper clipper) {
    155         return new RuntimeDragAndDropManager(context, clipper);
    156     }
    157 
    158     class RuntimeDragAndDropManager implements DragAndDropManager {
    159         private static final String SRC_ROOT_KEY = "dragAndDropMgr:srcRoot";
    160 
    161         private final Context mContext;
    162         private final DocumentClipper mClipper;
    163         private final DragShadowBuilder mShadowBuilder;
    164         private final Drawable mDefaultShadowIcon;
    165 
    166         private @State int mState = STATE_UNKNOWN;
    167 
    168         // Key events info. This is used to derive state when user drags items into a view to derive
    169         // type of file operations.
    170         private boolean mIsCtrlPressed;
    171 
    172         // Drag events info. These are used to derive state and update drag shadow when user changes
    173         // Ctrl key state.
    174         private View mView;
    175         private List<Uri> mInvalidDest;
    176         private ClipData mClipData;
    177         private RootInfo mDestRoot;
    178         private DocumentInfo mDestDoc;
    179 
    180         // Boolean flag for current drag and drop operation. Returns true if the files can only
    181         // be copied (ie. files that don't support delete or remove).
    182         private boolean mMustBeCopied;
    183 
    184         private RuntimeDragAndDropManager(Context context, DocumentClipper clipper) {
    185             this(
    186                     context.getApplicationContext(),
    187                     clipper,
    188                     new DragShadowBuilder(context),
    189                     context.getDrawable(R.drawable.ic_doc_generic));
    190         }
    191 
    192         @VisibleForTesting
    193         RuntimeDragAndDropManager(Context context, DocumentClipper clipper,
    194                 DragShadowBuilder builder, Drawable defaultShadowIcon) {
    195             mContext = context;
    196             mClipper = clipper;
    197             mShadowBuilder = builder;
    198             mDefaultShadowIcon = defaultShadowIcon;
    199         }
    200 
    201         @Override
    202         public void onKeyEvent(KeyEvent event) {
    203             switch (event.getKeyCode()) {
    204                 case KeyEvent.KEYCODE_CTRL_LEFT:
    205                 case KeyEvent.KEYCODE_CTRL_RIGHT:
    206                     adjustCtrlKeyCount(event);
    207             }
    208         }
    209 
    210         private void adjustCtrlKeyCount(KeyEvent event) {
    211             assert(event.getKeyCode() == KeyEvent.KEYCODE_CTRL_LEFT
    212                     || event.getKeyCode() == KeyEvent.KEYCODE_CTRL_RIGHT);
    213 
    214             mIsCtrlPressed = event.isCtrlPressed();
    215 
    216             // There is an ongoing drag and drop if mView is not null.
    217             if (mView != null) {
    218                 // There is no need to update the state if current state is unknown or not allowed.
    219                 if (mState == STATE_COPY || mState == STATE_MOVE) {
    220                     updateState(mView, mDestRoot, mDestDoc);
    221                 }
    222             }
    223         }
    224 
    225         @Override
    226         public void startDrag(
    227                 View v,
    228                 List<DocumentInfo> srcs,
    229                 RootInfo root,
    230                 List<Uri> invalidDest,
    231                 SelectionDetails selectionDetails,
    232                 IconHelper iconHelper,
    233                 @Nullable DocumentInfo parent) {
    234 
    235             mView = v;
    236             mInvalidDest = invalidDest;
    237             mMustBeCopied = !selectionDetails.canDelete();
    238 
    239             List<Uri> uris = new ArrayList<>(srcs.size());
    240             for (DocumentInfo doc : srcs) {
    241                 uris.add(doc.derivedUri);
    242             }
    243             mClipData = (parent == null)
    244                     ? mClipper.getClipDataForDocuments(uris, FileOperationService.OPERATION_UNKNOWN)
    245                     : mClipper.getClipDataForDocuments(
    246                             uris, FileOperationService.OPERATION_UNKNOWN, parent);
    247             mClipData.getDescription().getExtras()
    248                     .putString(SRC_ROOT_KEY, root.getUri().toString());
    249 
    250             updateShadow(srcs, iconHelper);
    251 
    252             int flag = View.DRAG_FLAG_GLOBAL | View.DRAG_FLAG_OPAQUE;
    253             if (!selectionDetails.containsFilesInArchive()) {
    254                 flag |= View.DRAG_FLAG_GLOBAL_URI_READ
    255                         | View.DRAG_FLAG_GLOBAL_URI_WRITE;
    256             }
    257             startDragAndDrop(
    258                     v,
    259                     mClipData,
    260                     mShadowBuilder,
    261                     this, // Used to detect multi-window drag and drop
    262                     flag);
    263         }
    264 
    265         private void updateShadow(List<DocumentInfo> srcs, IconHelper iconHelper) {
    266             final String title;
    267             final Drawable icon;
    268 
    269             final int size = srcs.size();
    270             if (size == 1) {
    271                 DocumentInfo doc = srcs.get(0);
    272                 title = doc.displayName;
    273                 icon = iconHelper.getDocumentIcon(mContext, doc);
    274             } else {
    275                 title = mContext.getResources()
    276                         .getQuantityString(R.plurals.elements_dragged, size, size);
    277                 icon = mDefaultShadowIcon;
    278             }
    279 
    280             mShadowBuilder.updateTitle(title);
    281             mShadowBuilder.updateIcon(icon);
    282 
    283             mShadowBuilder.onStateUpdated(STATE_UNKNOWN);
    284         }
    285 
    286         /**
    287          * A workaround of that
    288          * {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)} is final.
    289          */
    290         @VisibleForTesting
    291         void startDragAndDrop(View v, ClipData clipData, DragShadowBuilder builder,
    292                 Object localState, int flags) {
    293             v.startDragAndDrop(clipData, builder, localState, flags);
    294         }
    295 
    296         @Override
    297         public boolean canSpringOpen(RootInfo root, DocumentInfo doc) {
    298             return isValidDestination(root, doc.derivedUri);
    299         }
    300 
    301         @Override
    302         public void updateStateToNotAllowed(View v) {
    303             mView = v;
    304             updateState(STATE_NOT_ALLOWED);
    305         }
    306 
    307         @Override
    308         public @State int updateState(
    309                 View v, RootInfo destRoot, @Nullable DocumentInfo destDoc) {
    310 
    311             mView = v;
    312             mDestRoot = destRoot;
    313             mDestDoc = destDoc;
    314 
    315             if (!destRoot.supportsCreate()) {
    316                 updateState(STATE_NOT_ALLOWED);
    317                 return STATE_NOT_ALLOWED;
    318             }
    319 
    320             if (destDoc == null) {
    321                 updateState(STATE_UNKNOWN);
    322                 return STATE_UNKNOWN;
    323             }
    324 
    325             assert(destDoc.isDirectory());
    326 
    327             if (!destDoc.isCreateSupported() || mInvalidDest.contains(destDoc.derivedUri)) {
    328                 updateState(STATE_NOT_ALLOWED);
    329                 return STATE_NOT_ALLOWED;
    330             }
    331 
    332             @State int state;
    333             final @OpType int opType = calculateOpType(mClipData, destRoot);
    334             switch (opType) {
    335                 case FileOperationService.OPERATION_COPY:
    336                     state = STATE_COPY;
    337                     break;
    338                 case FileOperationService.OPERATION_MOVE:
    339                     state = STATE_MOVE;
    340                     break;
    341                 default:
    342                     // Should never happen
    343                     throw new IllegalStateException("Unknown opType: " + opType);
    344             }
    345 
    346             updateState(state);
    347             return state;
    348         }
    349 
    350         @Override
    351         public void resetState(View v) {
    352             mView = v;
    353 
    354             updateState(STATE_UNKNOWN);
    355         }
    356 
    357         private void updateState(@State int state) {
    358             mState = state;
    359 
    360             mShadowBuilder.onStateUpdated(state);
    361             updateDragShadow(mView);
    362         }
    363 
    364         /**
    365          * A workaround of that {@link View#updateDragShadow(View.DragShadowBuilder)} is final.
    366          */
    367         @VisibleForTesting
    368         void updateDragShadow(View v) {
    369             v.updateDragShadow(mShadowBuilder);
    370         }
    371 
    372         @Override
    373         public boolean drop(ClipData clipData, Object localState, RootInfo destRoot,
    374                 ActionHandler action, FileOperations.Callback callback) {
    375 
    376             final Uri rootDocUri =
    377                     DocumentsContract.buildDocumentUri(destRoot.authority, destRoot.documentId);
    378             if (!isValidDestination(destRoot, rootDocUri)) {
    379                 return false;
    380             }
    381 
    382             // Calculate the op type now just in case user releases Ctrl key while we're obtaining
    383             // root document in the background.
    384             final @OpType int opType = calculateOpType(clipData, destRoot);
    385             action.getRootDocument(
    386                     destRoot,
    387                     TimeoutTask.DEFAULT_TIMEOUT,
    388                     (DocumentInfo doc) -> {
    389                         dropOnRootDocument(clipData, localState, destRoot, doc, opType, callback);
    390                     });
    391 
    392             return true;
    393         }
    394 
    395         private void dropOnRootDocument(
    396                 ClipData clipData,
    397                 Object localState,
    398                 RootInfo destRoot,
    399                 @Nullable DocumentInfo destRootDoc,
    400                 @OpType int opType,
    401                 FileOperations.Callback callback) {
    402             if (destRootDoc == null) {
    403                 callback.onOperationResult(
    404                         FileOperations.Callback.STATUS_FAILED,
    405                         opType,
    406                         0);
    407             } else {
    408                 dropChecked(
    409                         clipData,
    410                         localState,
    411                         new DocumentStack(destRoot, destRootDoc),
    412                         opType,
    413                         callback);
    414             }
    415         }
    416 
    417         @Override
    418         public boolean drop(ClipData clipData, Object localState, DocumentStack dstStack,
    419                 FileOperations.Callback callback) {
    420 
    421             if (!canCopyTo(dstStack)) {
    422                 return false;
    423             }
    424 
    425             dropChecked(
    426                     clipData,
    427                     localState,
    428                     dstStack,
    429                     calculateOpType(clipData, dstStack.getRoot()),
    430                     callback);
    431             return true;
    432         }
    433 
    434         private void dropChecked(ClipData clipData, Object localState, DocumentStack dstStack,
    435                 @OpType int opType, FileOperations.Callback callback) {
    436 
    437             // Recognize multi-window drag and drop based on the fact that localState is not
    438             // carried between processes. It will stop working when the localsState behavior
    439             // is changed. The info about window should be passed in the localState then.
    440             // The localState could also be null for copying from Recents in single window
    441             // mode, but Recents doesn't offer this functionality (no directories).
    442             Metrics.logUserAction(mContext,
    443                     localState == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
    444                             : Metrics.USER_ACTION_DRAG_N_DROP);
    445 
    446             mClipper.copyFromClipData(dstStack, clipData, opType, callback);
    447         }
    448 
    449         @Override
    450         public void dragEnded() {
    451             // Multiple drag listeners might delegate drag ended event to this method, so anything
    452             // in this method needs to be idempotent. Otherwise we need to designate one listener
    453             // that always exists and only let it notify us when drag ended, which will further
    454             // complicate code and introduce one more coupling. This is a Android framework
    455             // limitation.
    456 
    457             mView = null;
    458             mInvalidDest = null;
    459             mClipData = null;
    460             mDestDoc = null;
    461             mDestRoot = null;
    462             mMustBeCopied = false;
    463         }
    464 
    465         private @OpType int calculateOpType(ClipData clipData, RootInfo destRoot) {
    466             if (mMustBeCopied) {
    467                 return FileOperationService.OPERATION_COPY;
    468             }
    469 
    470             final String srcRootUri = clipData.getDescription().getExtras().getString(SRC_ROOT_KEY);
    471             final String destRootUri = destRoot.getUri().toString();
    472 
    473             assert(srcRootUri != null);
    474             assert(destRootUri != null);
    475 
    476             if (srcRootUri.equals(destRootUri)) {
    477                 return mIsCtrlPressed
    478                         ? FileOperationService.OPERATION_COPY
    479                         : FileOperationService.OPERATION_MOVE;
    480             } else {
    481                 return mIsCtrlPressed
    482                         ? FileOperationService.OPERATION_MOVE
    483                         : FileOperationService.OPERATION_COPY;
    484             }
    485         }
    486 
    487         private boolean canCopyTo(DocumentStack dstStack) {
    488             final RootInfo root = dstStack.getRoot();
    489             final DocumentInfo dst = dstStack.peek();
    490             return isValidDestination(root, dst.derivedUri);
    491         }
    492 
    493         private boolean isValidDestination(RootInfo root, Uri dstUri) {
    494             return root.supportsCreate()  && !mInvalidDest.contains(dstUri);
    495         }
    496     }
    497 }
    498