Add capability to spring load a root or directory.

Bug: 28242270
Change-Id: I5cbdb7734af8d78015a82fd7b38b1266576c9914
(cherry picked from commit b5e2714d63dc5e74cef69f590b41a161eeca7b80)
diff --git a/packages/DocumentsUI/res/values/tags.xml b/packages/DocumentsUI/res/values/tags.xml
new file mode 100644
index 0000000..1c4b0ca
--- /dev/null
+++ b/packages/DocumentsUI/res/values/tags.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<resources>
+    <item name="drag_hovering_tag" type="id" />
+    <item name="item_position_tag" type="id" />
+</resources>
\ No newline at end of file
diff --git a/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java b/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java
index 7444797..8ef4530 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java
@@ -398,6 +398,13 @@
     }
 
     /**
+     * This is called when user hovers over a doc for enough time during a drag n' drop, to open a
+     * folder that accepts drop. We should only open a container that's not an archive.
+     */
+    public void springOpenDirectory(DocumentInfo doc) {
+    }
+
+    /**
      * Called when search results changed.
      * Refreshes the content of the directory. It doesn't refresh elements on the action bar.
      * e.g. The current directory name displayed on the action bar won't get updated.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
index f067d5f..60c37aa 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/FilesActivity.java
@@ -312,6 +312,13 @@
         }
     }
 
+    @Override
+    public void springOpenDirectory(DocumentInfo doc) {
+        assert(doc.isContainer());
+        assert(!doc.isArchive());
+        openContainerDocument(doc);
+    }
+
     /**
      * Launches an intent to view the specified document.
      */
diff --git a/packages/DocumentsUI/src/com/android/documentsui/ItemDragListener.java b/packages/DocumentsUI/src/com/android/documentsui/ItemDragListener.java
new file mode 100644
index 0000000..2c018f8
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/ItemDragListener.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui;
+
+import android.content.ClipData;
+import android.util.Log;
+import android.view.DragEvent;
+import android.view.View;
+import android.view.View.OnDragListener;
+import android.view.ViewConfiguration;
+
+import com.android.documentsui.ItemDragListener.DragHost;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * An {@link OnDragListener} that adds support for "spring loading views". Use this when you want
+ * items to pop-open when user hovers on them during a drag n drop.
+ */
+public class ItemDragListener<H extends DragHost> implements OnDragListener {
+
+    private static final String TAG = "ItemDragListener";
+
+    @VisibleForTesting
+    static final int SPRING_TIMEOUT = ViewConfiguration.getLongPressTimeout();
+
+    protected final H mDragHost;
+    private final Timer mHoverTimer;
+
+    public ItemDragListener(H dragHost) {
+        this(dragHost, new Timer());
+    }
+
+    @VisibleForTesting
+    protected ItemDragListener(H dragHost, Timer timer) {
+        mDragHost = dragHost;
+        mHoverTimer = timer;
+    }
+
+    @Override
+    public boolean onDrag(final View v, DragEvent event) {
+        switch (event.getAction()) {
+            case DragEvent.ACTION_DRAG_STARTED:
+                return true;
+            case DragEvent.ACTION_DRAG_ENTERED:
+                handleEnteredEvent(v);
+                return true;
+            case DragEvent.ACTION_DRAG_LOCATION:
+                return true;
+            case DragEvent.ACTION_DRAG_EXITED:
+            case DragEvent.ACTION_DRAG_ENDED:
+                handleExitedEndedEvent(v);
+                return true;
+            case DragEvent.ACTION_DROP:
+                return handleDropEvent(v, event);
+        }
+
+        return false;
+    }
+
+    private void handleEnteredEvent(View v) {
+        mDragHost.setDropTargetHighlight(v, true);
+
+        TimerTask task = createOpenTask(v);
+        assert (task != null);
+        v.setTag(R.id.drag_hovering_tag, task);
+        mHoverTimer.schedule(task, ViewConfiguration.getLongPressTimeout());
+    }
+
+    private void handleExitedEndedEvent(View v) {
+        mDragHost.setDropTargetHighlight(v, false);
+
+        TimerTask task = (TimerTask) v.getTag(R.id.drag_hovering_tag);
+        if (task != null) {
+            task.cancel();
+        }
+    }
+
+    private boolean handleDropEvent(View v, DragEvent event) {
+        ClipData clipData = event.getClipData();
+        if (clipData == null) {
+            Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring.");
+            return false;
+        }
+
+        return handleDropEventChecked(v, event);
+    }
+
+    @VisibleForTesting
+    TimerTask createOpenTask(final View v) {
+        TimerTask task = new TimerTask() {
+            @Override
+            public void run() {
+                mDragHost.runOnUiThread(() -> {
+                    mDragHost.onViewHovered(v);
+                });
+            }
+        };
+        return task;
+    }
+
+    /**
+     * Handles a drop event. Override it if you want to do something on drop event. It's called when
+     * {@link DragEvent#ACTION_DROP} happens. ClipData in DragEvent is guaranteed not null.
+     *
+     * @param v The view where user drops.
+     * @param event the drag event.
+     * @return true if this event is consumed; false otherwise
+     */
+    public boolean handleDropEventChecked(View v, DragEvent event) {
+        return false; // we didn't handle the drop
+    }
+
+    /**
+     * An interface {@link ItemDragListener} uses to make some callbacks.
+     */
+    public interface DragHost {
+
+        /**
+         * Runs this runnable in main thread.
+         */
+        void runOnUiThread(Runnable runnable);
+
+        /**
+         * Highlights/unhighlights the view to visually indicate this view is being hovered.
+         * @param v the view being hovered
+         * @param highlight true if highlight the view; false if unhighlight it
+         */
+        void setDropTargetHighlight(View v, boolean highlight);
+
+        /**
+         * Notifies hovering timeout has elapsed
+         * @param v the view being hovered
+         */
+        void onViewHovered(View v);
+    }
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/Metrics.java b/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
index 79123d0..196b0cd 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/Metrics.java
@@ -29,7 +29,6 @@
 import android.net.Uri;
 import android.provider.DocumentsContract;
 import android.util.Log;
-import android.view.KeyEvent;
 
 import com.android.documentsui.State.ActionType;
 import com.android.documentsui.model.DocumentInfo;
@@ -349,7 +348,7 @@
     }
 
     /**
-     * Logs a root visited event. Call this when the user clicks on a root in the RootsFragment.
+     * Logs a root visited event. Call this when the user visits on a root in the RootsFragment.
      *
      * @param context
      * @param info
@@ -359,7 +358,7 @@
     }
 
     /**
-     * Logs an app visited event. Call this when the user clicks on an app in the RootsFragment.
+     * Logs an app visited event. Call this when the user visits on an app in the RootsFragment.
      *
      * @param context
      * @param info
diff --git a/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java b/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java
index a98b5d0..8f0113c 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java
@@ -31,12 +31,14 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.provider.Settings;
+import android.support.annotation.ColorRes;
 import android.support.annotation.Nullable;
 import android.text.TextUtils;
 import android.text.format.Formatter;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
+import android.view.View.OnDragListener;
 import android.view.ViewGroup;
 import android.widget.AdapterView;
 import android.widget.AdapterView.OnItemClickListener;
@@ -58,7 +60,7 @@
 /**
  * Display list of known storage backend roots.
  */
-public class RootsFragment extends Fragment {
+public class RootsFragment extends Fragment implements ItemDragListener.DragHost {
 
     private static final String TAG = "RootsFragment";
     private static final String EXTRA_INCLUDE_APPS = "includeApps";
@@ -67,7 +69,6 @@
     private RootsAdapter mAdapter;
     private LoaderCallbacks<Collection<RootInfo>> mCallbacks;
 
-
     public static void show(FragmentManager fm, Intent includeApps) {
         final Bundle args = new Bundle();
         args.putParcelable(EXTRA_INCLUDE_APPS, includeApps);
@@ -118,7 +119,8 @@
 
                 Intent handlerAppIntent = getArguments().getParcelable(EXTRA_INCLUDE_APPS);
 
-                mAdapter = new RootsAdapter(context, result, handlerAppIntent, state);
+                mAdapter = new RootsAdapter(context, result, handlerAppIntent, state,
+                        new ItemDragListener<>(RootsFragment.this));
                 mList.setAdapter(mAdapter);
 
                 onCurrentRootChanged();
@@ -184,25 +186,53 @@
         startActivity(intent);
     }
 
+    private void openItem(int position) {
+        Item item = mAdapter.getItem(position);
+        if (item instanceof RootItem) {
+            BaseActivity activity = BaseActivity.get(this);
+            RootInfo newRoot = ((RootItem) item).root;
+            Metrics.logRootVisited(getActivity(), newRoot);
+            activity.onRootPicked(newRoot);
+        } else if (item instanceof AppItem) {
+            DocumentsActivity activity = DocumentsActivity.get(this);
+            ResolveInfo info = ((AppItem) item).info;
+            Metrics.logAppVisited(getActivity(), info);
+            activity.onAppPicked(info);
+        } else if (item instanceof SpacerItem) {
+            if (DEBUG) Log.d(TAG, "Ignoring click/hover on spacer item.");
+        } else {
+            throw new IllegalStateException("Unknown root: " + item);
+        }
+    }
+
+    @Override
+    public void runOnUiThread(Runnable runnable) {
+        getActivity().runOnUiThread(runnable);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * In RootsFragment we open the hovered root.
+     */
+    @Override
+    public void onViewHovered(View view) {
+        int position = (Integer) view.getTag(R.id.item_position_tag);
+        openItem(position);
+    }
+
+    @Override
+    public void setDropTargetHighlight(View v, boolean highlight) {
+        @ColorRes int colorId = highlight ? R.color.item_doc_background_selected
+                : android.R.color.transparent;
+
+        v.setBackgroundColor(getActivity().getColor(colorId));
+    }
+
     private OnItemClickListener mItemListener = new OnItemClickListener() {
         @Override
         public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
-            Item item = mAdapter.getItem(position);
-            if (item instanceof RootItem) {
-                BaseActivity activity = BaseActivity.get(RootsFragment.this);
-                RootInfo newRoot = ((RootItem) item).root;
-                Metrics.logRootVisited(getActivity(), newRoot);
-                activity.onRootPicked(newRoot);
-            } else if (item instanceof AppItem) {
-                DocumentsActivity activity = DocumentsActivity.get(RootsFragment.this);
-                ResolveInfo info = ((AppItem) item).info;
-                Metrics.logAppVisited(getActivity(), info);
-                activity.onAppPicked(info);
-            } else if (item instanceof SpacerItem) {
-                if (DEBUG) Log.d(TAG, "Ignoring click on spacer item.");
-            } else {
-                throw new IllegalStateException("Unknown root: " + item);
-            }
+            openItem(position);
         }
     };
 
@@ -236,7 +266,9 @@
             return convertView;
         }
 
-        public abstract void bindView(View convertView);
+        abstract void bindView(View convertView);
+
+        abstract boolean isDropTarget();
     }
 
     private static class RootItem extends Item {
@@ -267,6 +299,11 @@
             summary.setText(summaryText);
             summary.setVisibility(TextUtils.isEmpty(summaryText) ? View.GONE : View.VISIBLE);
         }
+
+        @Override
+        boolean isDropTarget() {
+            return root.supportsCreate() && !root.isLibrary();
+        }
     }
 
     private static class SpacerItem extends Item {
@@ -275,9 +312,14 @@
         }
 
         @Override
-        public void bindView(View convertView) {
+        void bindView(View convertView) {
             // Nothing to bind
         }
+
+        @Override
+        boolean isDropTarget() {
+            return false;
+        }
     }
 
     private static class AppItem extends Item {
@@ -289,7 +331,7 @@
         }
 
         @Override
-        public void bindView(View convertView) {
+        void bindView(View convertView) {
             final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon);
             final TextView title = (TextView) convertView.findViewById(android.R.id.title);
             final TextView summary = (TextView) convertView.findViewById(android.R.id.summary);
@@ -301,16 +343,24 @@
             // TODO: match existing summary behavior from disambig dialog
             summary.setVisibility(View.GONE);
         }
+
+        @Override
+        boolean isDropTarget() {
+            // We won't support drag n' drop in DocumentsActivity, and apps only show up there.
+            return false;
+        }
     }
 
     private static class RootsAdapter extends ArrayAdapter<Item> {
 
+        private OnDragListener mDragListener;
+
         /**
-         * @param handlerAppIntent When not null, apps capable of handling the original
-         *     intent will be included in list of roots (in special section at bottom).
+         * @param handlerAppIntent When not null, apps capable of handling the original intent will
+         *            be included in list of roots (in special section at bottom).
          */
         public RootsAdapter(Context context, Collection<RootInfo> roots,
-                @Nullable Intent handlerAppIntent, State state) {
+                @Nullable Intent handlerAppIntent, State state, OnDragListener dragListener) {
             super(context, 0);
 
             final List<RootItem> libraries = new ArrayList<>();
@@ -320,7 +370,8 @@
                 final RootItem item = new RootItem(root);
 
                 if (root.isHome() &&
-                        !Shared.shouldShowDocumentsRoot(context, ((Activity) context).getIntent())) {
+                        !Shared.shouldShowDocumentsRoot(context,
+                                ((Activity) context).getIntent())) {
                     continue;
                 } else if (root.isLibrary()) {
                     if (DEBUG) Log.d(TAG, "Adding " + root + " as library.");
@@ -346,11 +397,13 @@
             if (handlerAppIntent != null) {
                 includeHandlerApps(context, handlerAppIntent);
             }
+
+            mDragListener = dragListener;
         }
 
         /**
-         * Adds apps capable of handling the original intent will be included
-         * in list of roots (in special section at bottom).
+         * Adds apps capable of handling the original intent will be included in list of roots (in
+         * special section at bottom).
          */
         private void includeHandlerApps(Context context, Intent handlerAppIntent) {
             final PackageManager pm = context.getPackageManager();
@@ -375,7 +428,16 @@
         @Override
         public View getView(int position, View convertView, ViewGroup parent) {
             final Item item = getItem(position);
-            return item.getView(convertView, parent);
+            final View view = item.getView(convertView, parent);
+
+            if (item.isDropTarget()) {
+                view.setTag(R.id.item_position_tag, position);
+                view.setOnDragListener(mDragListener);
+            } else {
+                view.setTag(R.id.item_position_tag, null);
+                view.setOnDragListener(null);
+            }
+            return view;
         }
 
         @Override
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java
new file mode 100644
index 0000000..e8361a1
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryDragListener.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.dirlist;
+
+import android.view.DragEvent;
+import android.view.View;
+
+import com.android.documentsui.ItemDragListener;
+
+class DirectoryDragListener extends ItemDragListener<DirectoryFragment> {
+
+    DirectoryDragListener(DirectoryFragment fragment) {
+        super(fragment);
+    }
+
+    @Override
+    public boolean onDrag(View v, DragEvent event) {
+        final boolean result = super.onDrag(v, event);
+
+        if (event.getAction() == DragEvent.ACTION_DRAG_ENDED && event.getResult()) {
+            mDragHost.clearSelection();
+        }
+
+        return result;
+    }
+
+    @Override
+    public boolean handleDropEventChecked(View v, DragEvent event) {
+        return mDragHost.handleDropEvent(v, event);
+    }
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index dd9ade6..eec12c8 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -24,6 +24,8 @@
 import static com.android.documentsui.model.DocumentInfo.getCursorInt;
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
 
+import com.google.common.collect.Lists;
+
 import android.annotation.IntDef;
 import android.annotation.StringRes;
 import android.app.Activity;
@@ -48,7 +50,6 @@
 import android.os.Bundle;
 import android.os.Parcel;
 import android.os.Parcelable;
-import android.os.PersistableBundle;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Document;
 import android.support.annotation.Nullable;
@@ -87,6 +88,7 @@
 import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.Events;
 import com.android.documentsui.Events.MotionInputEvent;
+import com.android.documentsui.ItemDragListener;
 import com.android.documentsui.Menus;
 import com.android.documentsui.MessageBar;
 import com.android.documentsui.Metrics;
@@ -106,8 +108,6 @@
 import com.android.documentsui.services.FileOperationService.OpType;
 import com.android.documentsui.services.FileOperations;
 
-import com.google.common.collect.Lists;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -121,7 +121,8 @@
  * Display the documents inside a single directory.
  */
 public class DirectoryFragment extends Fragment
-        implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult> {
+        implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult>,
+        ItemDragListener.DragHost {
 
     @IntDef(flag = true, value = {
             TYPE_NORMAL,
@@ -177,6 +178,8 @@
     private boolean mSearchMode = false;
     private @Nullable ActionMode mActionMode;
 
+    private DirectoryDragListener mOnDragListener;
+
     @Override
     public View onCreateView(
             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -197,6 +200,8 @@
 
         mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity()));
 
+        mOnDragListener = new DirectoryDragListener(this);
+
         // Make the recycler and the empty views responsive to drop events.
         mRecView.setOnDragListener(mOnDragListener);
         mEmptyView.setOnDragListener(mOnDragListener);
@@ -1253,103 +1258,86 @@
         }
     }
 
-    private View.OnDragListener mOnDragListener = new View.OnDragListener() {
-        @Override
-        public boolean onDrag(View v, DragEvent event) {
-            switch (event.getAction()) {
-                case DragEvent.ACTION_DRAG_STARTED:
-                    // TODO: Check if the event contains droppable data.
-                    return true;
+    public void clearSelection() {
+        mSelectionManager.clearSelection();
+    }
 
-                // TODO: Expand drop target directory on hover?
-                case DragEvent.ACTION_DRAG_ENTERED:
-                    setDropTargetHighlight(v, true);
-                    return true;
-                case DragEvent.ACTION_DRAG_EXITED:
-                    setDropTargetHighlight(v, false);
-                    return true;
+    @Override
+    public void runOnUiThread(Runnable runnable) {
+        getActivity().runOnUiThread(runnable);
+    }
 
-                case DragEvent.ACTION_DRAG_LOCATION:
-                    return true;
+    /**
+     * {@inheritDoc}
+     *
+     * In DirectoryFragment, we spring loads the hovered folder.
+     */
+    @Override
+    public void onViewHovered(View view) {
+        if (getModelId(view) != null) {
+            ((BaseActivity) getActivity()).springOpenDirectory(getDestination(view));
+        }
+    }
 
-                case DragEvent.ACTION_DRAG_ENDED:
-                    // After a drop event, always stop highlighting the target.
-                    setDropTargetHighlight(v, false);
-                    if (event.getResult()) {
-                        // Exit selection mode if the drop was handled.
-                        mSelectionManager.clearSelection();
-                    }
-                    return true;
+    public boolean handleDropEvent(View v, DragEvent event) {
+        ClipData clipData = event.getClipData();
+        assert (clipData != null);
 
-                case DragEvent.ACTION_DROP:
-                return handleDropEvent(v, event);
-            }
+        ClipDetails clipDetails = mClipper.getClipDetails(clipData);
+        assert(clipDetails.opType == FileOperationService.OPERATION_COPY);
+
+        // Don't copy from the cwd into the cwd. Note: this currently doesn't work for
+        // multi-window drag, because localState isn't carried over from one process to
+        // another.
+        Object src = event.getLocalState();
+        DocumentInfo dst = getDestination(v);
+        if (Objects.equals(src, dst)) {
+            if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring.");
             return false;
         }
 
-        private boolean handleDropEvent(View v, DragEvent event) {
+        // Recognize multi-window drag and drop based on the fact that localState is not
+        // carried between processes. It will stop working when the localsState behavior
+        // is changed. The info about window should be passed in the localState then.
+        // The localState could also be null for copying from Recents in single window
+        // mode, but Recents doesn't offer this functionality (no directories).
+        Metrics.logUserAction(getContext(),
+                src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
+                        : Metrics.USER_ACTION_DRAG_N_DROP);
 
-            ClipData clipData = event.getClipData();
-            if (clipData == null) {
-                Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring.");
-                return false;
+        copyFromClipData(clipData, dst);
+        return true;
+    }
+
+    private DocumentInfo getDestination(View v) {
+        String id = getModelId(v);
+        if (id != null) {
+            Cursor dstCursor = mModel.getItem(id);
+            if (dstCursor == null) {
+                Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id);
+                return null;
             }
-
-            ClipDetails clipDetails = mClipper.getClipDetails(clipData);
-            assert(clipDetails.opType == FileOperationService.OPERATION_COPY);
-
-            // Don't copy from the cwd into the cwd. Note: this currently doesn't work for
-            // multi-window drag, because localState isn't carried over from one process to
-            // another.
-            Object src = event.getLocalState();
-            DocumentInfo dst = getDestination(v);
-            if (Objects.equals(src, dst)) {
-                if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring.");
-                return false;
-            }
-
-            // Recognize multi-window drag and drop based on the fact that localState is not
-            // carried between processes. It will stop working when the localsState behavior
-            // is changed. The info about window should be passed in the localState then.
-            // The localState could also be null for copying from Recents in single window
-            // mode, but Recents doesn't offer this functionality (no directories).
-            Metrics.logUserAction(getContext(),
-                    src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW
-                            : Metrics.USER_ACTION_DRAG_N_DROP);
-
-            copyFromClipData(clipData, dst);
-            return true;
+            return DocumentInfo.fromDirectoryCursor(dstCursor);
         }
 
-        private DocumentInfo getDestination(View v) {
-            String id = getModelId(v);
-            if (id != null) {
-                Cursor dstCursor = mModel.getItem(id);
-                if (dstCursor == null) {
-                    Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id);
-                    return null;
-                }
-                return DocumentInfo.fromDirectoryCursor(dstCursor);
-            }
-
-            if (v == mRecView || v == mEmptyView) {
-                return getDisplayState().stack.peek();
-            }
-
-            return null;
+        if (v == mRecView || v == mEmptyView) {
+            return getDisplayState().stack.peek();
         }
 
-        private void setDropTargetHighlight(View v, boolean highlight) {
-            // Note: use exact comparison - this code is searching for views which are children of
-            // the RecyclerView instance in the UI.
-            if (v.getParent() == mRecView) {
-                RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v);
-                if (vh instanceof DocumentHolder) {
-                    ((DocumentHolder) vh).setHighlighted(highlight);
-                }
+        return null;
+    }
+
+    @Override
+    public void setDropTargetHighlight(View v, boolean highlight) {
+        // Note: use exact comparison - this code is searching for views which are children of
+        // the RecyclerView instance in the UI.
+        if (v.getParent() == mRecView) {
+            RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v);
+            if (vh instanceof DocumentHolder) {
+                ((DocumentHolder) vh).setHighlighted(highlight);
             }
         }
-    };
+    }
 
     /**
      * Gets the model ID for a given motion event (using the event position)
@@ -1373,7 +1361,7 @@
      * @return The Model ID for the given document, or null if the given view is not associated with
      *     a document item view.
      */
-    private String getModelId(View view) {
+    protected String getModelId(View view) {
         View itemView = mRecView.findContainingItemView(view);
         if (itemView != null) {
             RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView);
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/ItemDragListenerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/ItemDragListenerTest.java
new file mode 100644
index 0000000..050b8c8
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/ItemDragListenerTest.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui;
+
+import android.content.ClipData;
+import android.view.DragEvent;
+import android.view.View;
+
+import com.android.documentsui.testing.ClipDatas;
+import com.android.documentsui.testing.DragEvents;
+import com.android.documentsui.testing.TestTimer;
+import com.android.documentsui.testing.TestViews;
+
+import junit.framework.TestCase;
+
+import org.junit.Test;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+public class ItemDragListenerTest extends TestCase {
+
+    private static final long DELAY_AFTER_HOVERING = ItemDragListener.SPRING_TIMEOUT + 1;
+
+    private View mTestView;
+    private TestDragHost mTestDragHost;
+    private TestTimer mTestTimer;
+
+    private TestDragListener mListener;
+
+    @Override
+    public void setUp() {
+        mTestView = TestViews.createTestView();
+
+        mTestTimer = new TestTimer();
+        mTestDragHost = new TestDragHost();
+        mListener = new TestDragListener(mTestDragHost, mTestTimer);
+    }
+
+    @Test
+    public void testDragStarted_ReturnsTrue() {
+        assertTrue(triggerDragEvent(DragEvent.ACTION_DRAG_STARTED));
+    }
+
+    @Test
+    public void testDragEntered_HighlightsView() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+        assertSame(mTestView, mTestDragHost.mHighlightedView);
+    }
+
+    @Test
+    public void testDragExited_UnhighlightsView() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        triggerDragEvent(DragEvent.ACTION_DRAG_EXITED);
+        assertNull(mTestDragHost.mHighlightedView);
+    }
+
+    @Test
+    public void testDragEnded_UnhighlightsView() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENDED);
+        assertNull(mTestDragHost.mHighlightedView);
+    }
+
+    @Test
+    public void testHover_OpensView() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING);
+
+        assertSame(mTestView, mTestDragHost.mLastOpenedView);
+    }
+
+    @Test
+    public void testDragExited_CancelsHoverTask() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        triggerDragEvent(DragEvent.ACTION_DRAG_EXITED);
+
+        mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING);
+
+        assertNull(mTestDragHost.mLastOpenedView);
+    }
+
+    @Test
+    public void testDragEnded_CancelsHoverTask() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENDED);
+
+        mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING);
+
+        assertNull(mTestDragHost.mLastOpenedView);
+    }
+
+    @Test
+    public void testNoDropWithoutClipData() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        final DragEvent dropEvent = DragEvents.createTestDropEvent(null);
+        assertFalse(mListener.onDrag(mTestView, dropEvent));
+    }
+
+    @Test
+    public void testDoDropWithClipData() {
+        triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
+
+        final ClipData data = ClipDatas.createTestClipData();
+        final DragEvent dropEvent = DragEvents.createTestDropEvent(data);
+        mListener.onDrag(mTestView, dropEvent);
+
+        assertSame(mTestView, mListener.mLastDropOnView);
+        assertSame(dropEvent, mListener.mLastDropEvent);
+    }
+
+    protected boolean triggerDragEvent(int actionId) {
+        final DragEvent testEvent = DragEvents.createTestDragEvent(actionId);
+
+        return mListener.onDrag(mTestView, testEvent);
+    }
+
+    private static class TestDragListener extends ItemDragListener<TestDragHost> {
+
+        private View mLastDropOnView;
+        private DragEvent mLastDropEvent;
+
+        protected TestDragListener(TestDragHost dragHost, Timer timer) {
+            super(dragHost, timer);
+        }
+
+        @Override
+        public TimerTask createOpenTask(View v) {
+            TimerTask task = super.createOpenTask(v);
+            TestTimer.Task testTask = new TestTimer.Task(task);
+
+            return testTask;
+        }
+
+        @Override
+        public boolean handleDropEventChecked(View v, DragEvent event) {
+            mLastDropOnView = v;
+            mLastDropEvent = event;
+            return true;
+        }
+
+    }
+
+    private static class TestDragHost implements ItemDragListener.DragHost {
+        private View mHighlightedView;
+        private View mLastOpenedView;
+
+        @Override
+        public void setDropTargetHighlight(View v, boolean highlight) {
+            mHighlightedView = highlight ? v : null;
+        }
+
+        @Override
+        public void runOnUiThread(Runnable runnable) {
+            runnable.run();
+        }
+
+        @Override
+        public void onViewHovered(View v) {
+            mLastOpenedView = v;
+        }
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDatas.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDatas.java
new file mode 100644
index 0000000..6536fb9
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/ClipDatas.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.testing;
+
+import android.content.ClipData;
+
+import org.mockito.Mockito;
+
+/**
+ * Test support for working with {@link ClipData} instances.
+ */
+public final class ClipDatas {
+
+    public static ClipData createTestClipData() {
+        ClipData data = Mockito.mock(ClipData.class);
+
+        return data;
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/DragEvents.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/DragEvents.java
new file mode 100644
index 0000000..e835caf
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/DragEvents.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.testing;
+
+import android.content.ClipData;
+import android.view.DragEvent;
+
+import org.mockito.Mockito;
+
+/**
+ * Test support for working with {@link DragEvents} instances.
+ */
+public final class DragEvents {
+
+    private DragEvents() {}
+
+    public static DragEvent createTestDragEvent(int actionId) {
+        final DragEvent mockEvent = Mockito.mock(DragEvent.class);
+        Mockito.when(mockEvent.getAction()).thenReturn(actionId);
+
+        return mockEvent;
+    }
+
+    public static DragEvent createTestDropEvent(ClipData clipData) {
+        final DragEvent dropEvent = createTestDragEvent(DragEvent.ACTION_DROP);
+        Mockito.when(dropEvent.getClipData()).thenReturn(clipData);
+
+        return dropEvent;
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java
new file mode 100644
index 0000000..428e8bd
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestTimer.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.testing;
+
+import java.util.Date;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.ListIterator;
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * A {@link Timer} for testing that can dial its clock hands to any future time.
+ */
+public class TestTimer extends Timer {
+
+    private long mNow = 0;
+
+    private final LinkedList<Task> mTaskList = new LinkedList<>();
+
+    public void fastForwardTo(long time) {
+        if (time < mNow) {
+            throw new IllegalArgumentException("Can't fast forward to past.");
+        }
+
+        mNow = time;
+        while (!mTaskList.isEmpty() && mTaskList.getFirst().mExecuteTime <= mNow) {
+            Task task = mTaskList.getFirst();
+            if (!task.isCancelled()) {
+                task.run();
+            }
+            mTaskList.removeFirst();
+        }
+    }
+
+    @Override
+    public void cancel() {
+        mTaskList.clear();
+    }
+
+    @Override
+    public int purge() {
+        int count = 0;
+        Iterator<Task> iter = mTaskList.iterator();
+        while (iter.hasNext()) {
+            Task task = iter.next();
+            if (task.isCancelled()) {
+                iter.remove();
+                ++count;
+            }
+        }
+        return count;
+    }
+
+    @Override
+    public void schedule(TimerTask task, Date time) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void schedule(TimerTask task, Date firstTime, long period) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void schedule(TimerTask task, long delay) {
+        long executeTime = mNow + delay;
+        Task testTimerTask = (Task) task;
+        testTimerTask.mExecuteTime = executeTime;
+
+        ListIterator<Task> iter = mTaskList.listIterator(0);
+        while (iter.hasNext()) {
+            if (iter.next().mExecuteTime >= executeTime) {
+                break;
+            }
+        }
+        iter.add(testTimerTask);
+    }
+
+    @Override
+    public void schedule(TimerTask task, long delay, long period) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void scheduleAtFixedRate(TimerTask task, long delay, long period) {
+        throw new UnsupportedOperationException();
+    }
+
+    public static class Task extends TimerTask {
+        private boolean mIsCancelled;
+        private long mExecuteTime;
+
+        private TimerTask mDelegate;
+
+        public Task(TimerTask delegate) {
+            mDelegate = delegate;
+        }
+
+        @Override
+        public boolean cancel() {
+            mIsCancelled = true;
+            return mDelegate.cancel();
+        }
+
+        @Override
+        public void run() {
+            mDelegate.run();
+        }
+
+        boolean isCancelled() {
+            return mIsCancelled;
+        }
+    }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestViews.java b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestViews.java
new file mode 100644
index 0000000..dd2bfec
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/testing/TestViews.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.testing;
+
+import android.view.View;
+
+import org.mockito.Mockito;
+
+/**
+ * Test support for working with {@link TestViews} instances.
+ */
+public final class TestViews {
+
+    private TestViews() {}
+
+    public static View createTestView() {
+        View view = Mockito.mock(View.class);
+        Mockito.doCallRealMethod().when(view).setTag(Mockito.anyInt(), Mockito.any());
+        Mockito.doCallRealMethod().when(view).getTag(Mockito.anyInt());
+
+        return view;
+    }
+}