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