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