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