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