Merge "Add Feature Flag support." into arc-apps
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index 3897dc3..9e69c09 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -20,7 +20,6 @@
 import static com.android.documentsui.base.DocumentInfo.getCursorString;
 
 import android.app.Activity;
-import android.content.ClipData;
 import android.content.Intent;
 import android.content.pm.ResolveInfo;
 import android.database.Cursor;
@@ -29,6 +28,7 @@
 import android.provider.DocumentsContract;
 import android.support.annotation.VisibleForTesting;
 import android.util.Log;
+import android.view.DragEvent;
 
 import com.android.documentsui.AbstractActionHandler.CommonAddons;
 import com.android.documentsui.LoadDocStackTask.LoadDocStackCallback;
@@ -184,7 +184,7 @@
     }
 
     @Override
-    public boolean dropOn(ClipData data, RootInfo root) {
+    public boolean dropOn(DragEvent event, RootInfo root) {
         throw new UnsupportedOperationException("Can't open an app.");
     }
 
diff --git a/src/com/android/documentsui/ActionHandler.java b/src/com/android/documentsui/ActionHandler.java
index 0df824f..7b0eef4 100644
--- a/src/com/android/documentsui/ActionHandler.java
+++ b/src/com/android/documentsui/ActionHandler.java
@@ -16,10 +16,11 @@
 
 package com.android.documentsui;
 
-import android.content.ClipData;
+import android.content.ContentProvider;
 import android.content.Intent;
 import android.content.pm.ResolveInfo;
 import android.net.Uri;
+import android.view.DragEvent;
 
 import com.android.documentsui.base.BooleanConsumer;
 import com.android.documentsui.base.DocumentInfo;
@@ -37,7 +38,7 @@
     /**
      * Drops documents on a root.
      */
-    boolean dropOn(ClipData data, RootInfo root);
+    boolean dropOn(DragEvent event, RootInfo root);
 
     /**
      * Attempts to eject the identified root. Returns a boolean answer to listener.
diff --git a/src/com/android/documentsui/HorizontalBreadcrumb.java b/src/com/android/documentsui/HorizontalBreadcrumb.java
index dd77a48..d6410c8 100644
--- a/src/com/android/documentsui/HorizontalBreadcrumb.java
+++ b/src/com/android/documentsui/HorizontalBreadcrumb.java
@@ -32,7 +32,7 @@
 import com.android.documentsui.NavigationViewManager.Environment;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.RootInfo;
-import com.android.documentsui.dirlist.AccessibilityClickEventRouter;
+import com.android.documentsui.dirlist.AccessibilityEventRouter;
 
 import java.util.function.Consumer;
 import java.util.function.IntConsumer;
@@ -76,7 +76,7 @@
         // accessibility delegate to route click events correctly. See AccessibilityClickEventRouter
         // for more details on how we are routing these a11y events.
         setAccessibilityDelegateCompat(
-                new AccessibilityClickEventRouter(this,
+                new AccessibilityEventRouter(this,
                         (View child) -> onAccessibilityClick(child)));
 
         setLayoutManager(mLayoutManager);
diff --git a/src/com/android/documentsui/base/DocumentStack.java b/src/com/android/documentsui/base/DocumentStack.java
index df6fff8..1423666 100644
--- a/src/com/android/documentsui/base/DocumentStack.java
+++ b/src/com/android/documentsui/base/DocumentStack.java
@@ -84,9 +84,10 @@
     public DocumentStack(DocumentStack src, DocumentInfo... docs) {
         mList = new LinkedList<>(src.mList);
         for (DocumentInfo doc : docs) {
-            mList.addLast(doc);
+            push(doc);
         }
 
+        mStackTouched = false;
         mRoot = src.mRoot;
     }
 
@@ -118,9 +119,13 @@
     }
 
     public void push(DocumentInfo info) {
-        if (DEBUG) Log.d(TAG, "Adding doc to stack: " + info);
-        mList.addLast(info);
-        mStackTouched = true;
+        boolean alreadyInStack = mList.contains(info);
+        assert (!alreadyInStack);
+        if (!alreadyInStack) {
+            if (DEBUG) Log.d(TAG, "Adding doc to stack: " + info);
+            mList.addLast(info);
+            mStackTouched = true;
+        }
     }
 
     public DocumentInfo pop() {
diff --git a/src/com/android/documentsui/clipping/DocumentClipper.java b/src/com/android/documentsui/clipping/DocumentClipper.java
index d0dfc7c..1e307ce 100644
--- a/src/com/android/documentsui/clipping/DocumentClipper.java
+++ b/src/com/android/documentsui/clipping/DocumentClipper.java
@@ -232,6 +232,20 @@
     }
 
     /**
+     * Copies documents from clipboard. It's the same as {@link #copyFromClipData} with clipData
+     * returned from {@link ClipboardManager#getPrimaryClip()}.
+     *
+     * @param docStack the document stack to the destination folder,
+     * @param callback callback to notify when operation finishes.
+     */
+    public void copyFromClipboard(
+            DocumentStack docStack,
+            FileOperations.Callback callback) {
+
+        copyFromClipData(docStack, mClipboard.getPrimaryClip(), callback);
+    }
+
+    /**
      * Copied documents from given clip data to a root directory.
      * @param root the root which root directory to copy to
      * @param destination the root directory
@@ -266,6 +280,14 @@
         copyFromClipData(dstStack, clipData, callback);
     }
 
+    /**
+     * Copies documents from given clip data to a folder.
+     *
+     * @param docStack the document stack to the destination folder, including the destination
+     *            folder.
+     * @param clipData the clipData to copy from
+     * @param callback callback to notify when operation finishes
+     */
     private void copyFromClipData(
             final DocumentStack dstStack,
             final @Nullable ClipData clipData,
diff --git a/src/com/android/documentsui/dirlist/AccessibilityClickEventRouter.java b/src/com/android/documentsui/dirlist/AccessibilityEventRouter.java
similarity index 73%
rename from src/com/android/documentsui/dirlist/AccessibilityClickEventRouter.java
rename to src/com/android/documentsui/dirlist/AccessibilityEventRouter.java
index 8cde993..9a8b342 100644
--- a/src/com/android/documentsui/dirlist/AccessibilityClickEventRouter.java
+++ b/src/com/android/documentsui/dirlist/AccessibilityEventRouter.java
@@ -28,20 +28,24 @@
 
 /**
  * Custom Accessibility Delegate for RecyclerViews to route click events on its child views to
- * proper handlers.
- *
- * The majority of event handling is done using TouchDetector instead of View.OnCLickListener,
- * which most a11y services use to understand whether a particular view is clickable or not.
- * Thus, we need to use a custom accessibility delegate to manually add ACTION_CLICK to clickable child
- * views' accessibility node, and then correctly route these clicks done by a11y services to responsible
+ * proper handlers, and to surface selection state to a11y events.
+ * <p>
+ * The majority of event handling isdone using TouchDetector instead of View.OnCLickListener, which
+ * most a11y services use to understand whether a particular view is clickable or not. Thus, we need
+ * to use a custom accessibility delegate to manually add ACTION_CLICK to clickable child views'
+ * accessibility node, and then correctly route these clicks done by a11y services to responsible
  * click callbacks.
+ * <p>
+ * DocumentsUI uses {@link View#setActivated(boolean)} instead of {@link View#setSelected(boolean)}
+ * for marking a view as selected. We will surface that selection state to a11y services in this
+ * class.
  */
-public class AccessibilityClickEventRouter extends RecyclerViewAccessibilityDelegate {
+public class AccessibilityEventRouter extends RecyclerViewAccessibilityDelegate {
 
     private final ItemDelegate mItemDelegate;
     private final Function<View, Boolean> mClickCallback;
 
-    public AccessibilityClickEventRouter(
+    public AccessibilityEventRouter(
             RecyclerView recyclerView, Function<View, Boolean> clickCallback) {
         super(recyclerView);
         mClickCallback = clickCallback;
@@ -51,6 +55,7 @@
                     AccessibilityNodeInfoCompat info) {
                 super.onInitializeAccessibilityNodeInfo(host, info);
                 info.addAction(AccessibilityActionCompat.ACTION_CLICK);
+                info.setSelected(host.isActivated());
             }
 
             @Override
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 57facaf3..3da4276 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -315,7 +315,7 @@
         mActions = mInjector.getActionHandler(mModel);
 
         mRecView.setAccessibilityDelegateCompat(
-                new AccessibilityClickEventRouter(mRecView,
+                new AccessibilityEventRouter(mRecView,
                         (View child) -> onAccessibilityClick(child)));
         mSelectionMetadata = new SelectionMetadata(mModel::getItem);
         mSelectionMgr.addItemCallback(mSelectionMetadata);
@@ -860,13 +860,14 @@
         return mInjector.config.isDocumentEnabled(mimeType, flags, mState);
     }
 
+    /**
+     * Paste selection files from the primary clip into the current window.
+     */
     public void pasteFromClipboard() {
         Metrics.logUserAction(getContext(), Metrics.USER_ACTION_PASTE_CLIPBOARD);
-
-        BaseActivity activity = (BaseActivity) getActivity();
-        DocumentInfo destination = activity.getCurrentDirectory();
+        // Since we are pasting into the current window, we already have the destination in the
+        // stack. No need for a destination DocumentInfo.
         mClipper.copyFromClipboard(
-                destination,
                 mState.stack,
                 mInjector.dialogs::showFileOperationStatus);
         getActivity().invalidateOptionsMenu();
@@ -881,7 +882,6 @@
             Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + modelId);
             return;
         }
-        BaseActivity activity = mActivity;
         DocumentInfo destination = DocumentInfo.fromDirectoryCursor(dstCursor);
         mClipper.copyFromClipboard(
                 destination,
@@ -968,11 +968,20 @@
                         : Metrics.USER_ACTION_DRAG_N_DROP);
 
         DocumentInfo dst = getDestination(v);
-        mClipper.copyFromClipData(
-                dst,
-                mState.stack,
-                clipData,
-                mInjector.dialogs::showFileOperationStatus);
+        // If destination is already at top of stack, no need to pass it in
+        if (!mState.stack.isEmpty() && mState.stack.peek().equals(dst)) {
+            mClipper.copyFromClipData(
+                    null,
+                    mState.stack,
+                    clipData,
+                    mInjector.dialogs::showFileOperationStatus);
+        } else {
+            mClipper.copyFromClipData(
+                    dst,
+                    mState.stack,
+                    clipData,
+                    mInjector.dialogs::showFileOperationStatus);
+        }
         return true;
     }
 
diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java
index 5a7f374..5720ea1 100644
--- a/src/com/android/documentsui/files/ActionHandler.java
+++ b/src/com/android/documentsui/files/ActionHandler.java
@@ -20,19 +20,20 @@
 
 import android.app.Activity;
 import android.content.ActivityNotFoundException;
-import android.content.ClipData;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Intent;
 import android.net.Uri;
 import android.provider.DocumentsContract;
 import android.util.Log;
+import android.view.DragEvent;
 
 import com.android.documentsui.AbstractActionHandler;
 import com.android.documentsui.ActionModeAddons;
 import com.android.documentsui.ActivityConfig;
 import com.android.documentsui.DocumentsAccess;
 import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.DragAndDropHelper;
 import com.android.documentsui.Injector;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.R;
@@ -108,17 +109,25 @@
     }
 
     @Override
-    public boolean dropOn(ClipData data, RootInfo root) {
+    public boolean dropOn(DragEvent event, RootInfo root) {
         new GetRootDocumentTask(
                 root,
                 mActivity,
                 mActivity::isDestroyed,
-                (DocumentInfo doc) -> mClipper.copyFromClipData(
-                        root, doc, data, mDialogs::showFileOperationStatus)
+                (DocumentInfo rootDoc) -> dropOnCallback(event, rootDoc, root)
         ).executeOnExecutor(mExecutors.lookup(root.authority));
         return true;
     }
 
+    private void dropOnCallback(DragEvent event, DocumentInfo rootDoc, RootInfo root) {
+        if (!DragAndDropHelper.canCopyTo(event.getLocalState(), rootDoc)) {
+            return;
+        }
+
+        mClipper.copyFromClipData(
+                root, rootDoc, event.getClipData(), mDialogs::showFileOperationStatus);
+    }
+
     @Override
     public void openSelectedInNewWindow() {
         Selection selection = getStableSelection();
diff --git a/src/com/android/documentsui/sidebar/Item.java b/src/com/android/documentsui/sidebar/Item.java
index 077385f..3b550e6 100644
--- a/src/com/android/documentsui/sidebar/Item.java
+++ b/src/com/android/documentsui/sidebar/Item.java
@@ -17,8 +17,7 @@
 package com.android.documentsui.sidebar;
 
 import android.annotation.LayoutRes;
-import android.content.ClipData;
-import android.content.Context;
+import android.view.DragEvent;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
@@ -27,7 +26,6 @@
 
 import com.android.documentsui.MenuManager;
 import com.android.documentsui.R;
-import com.android.documentsui.base.CheckedTask.Check;
 
 /**
  * Describes a root navigation point of documents. Each one of them is presented as an item in the
@@ -60,7 +58,7 @@
 
     abstract void open();
 
-    boolean dropOn(ClipData data) {
+    boolean dropOn(DragEvent event) {
         return false;
     }
 
diff --git a/src/com/android/documentsui/sidebar/RootItem.java b/src/com/android/documentsui/sidebar/RootItem.java
index 97ec89f..a4501a0 100644
--- a/src/com/android/documentsui/sidebar/RootItem.java
+++ b/src/com/android/documentsui/sidebar/RootItem.java
@@ -17,11 +17,11 @@
 package com.android.documentsui.sidebar;
 
 import android.annotation.Nullable;
-import android.content.ClipData;
 import android.content.Context;
 import android.provider.DocumentsProvider;
 import android.text.TextUtils;
 import android.text.format.Formatter;
+import android.view.DragEvent;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.View;
@@ -106,8 +106,8 @@
     }
 
     @Override
-    boolean dropOn(ClipData data) {
-        return mActionHandler.dropOn(data, root);
+    boolean dropOn(DragEvent event) {
+        return mActionHandler.dropOn(event, root);
     }
 
     @Override
diff --git a/src/com/android/documentsui/sidebar/RootsFragment.java b/src/com/android/documentsui/sidebar/RootsFragment.java
index 0debf72..7fbe8e9 100644
--- a/src/com/android/documentsui/sidebar/RootsFragment.java
+++ b/src/com/android/documentsui/sidebar/RootsFragment.java
@@ -201,7 +201,7 @@
 
                     assert (item.isDropTarget());
 
-                    return item.dropOn(event.getClipData());
+                    return item.dropOn(event);
                 }
             };
         }
diff --git a/tests/common/com/android/documentsui/PagingProvider.java b/tests/common/com/android/documentsui/PagingProvider.java
index e05cec5..f784255 100644
--- a/tests/common/com/android/documentsui/PagingProvider.java
+++ b/tests/common/com/android/documentsui/PagingProvider.java
@@ -91,7 +91,7 @@
         for (int i = 0; i < numItems; i++) {
             addFile(c, String.format("%05d", offset + i));
         }
-        extras.putInt(ContentResolver.QUERY_RESULT_SIZE, recordsetSize);
+        extras.putInt(ContentResolver.EXTRA_TOTAL_SIZE, recordsetSize);
         return c;
     }
 }
diff --git a/tests/common/com/android/documentsui/testing/Views.java b/tests/common/com/android/documentsui/testing/Views.java
index d2b920d..30f79a6 100644
--- a/tests/common/com/android/documentsui/testing/Views.java
+++ b/tests/common/com/android/documentsui/testing/Views.java
@@ -44,6 +44,13 @@
         return view;
     }
 
+    public static View createTestView(boolean activated) {
+        View view = createTestView();
+        Mockito.when(view.isActivated()).thenReturn(activated);
+
+        return view;
+    }
+
     public static void setBackground(View testView, Drawable background) {
         Mockito.when(testView.getBackground()).thenReturn(background);
     }
diff --git a/tests/unit/com/android/documentsui/AbstractActionHandlerTest.java b/tests/unit/com/android/documentsui/AbstractActionHandlerTest.java
index 02b5df5..18b4bb9 100644
--- a/tests/unit/com/android/documentsui/AbstractActionHandlerTest.java
+++ b/tests/unit/com/android/documentsui/AbstractActionHandlerTest.java
@@ -120,8 +120,6 @@
                 Arrays.asList(TestEnv.FOLDER_1.documentId, TestEnv.FOLDER_2.documentId));
         mEnv.docs.nextDocuments = Arrays.asList(TestEnv.FOLDER_1, TestEnv.FOLDER_2);
 
-        mEnv.state.stack.push(TestEnv.FOLDER_0);
-
         mHandler.openContainerDocument(TestEnv.FOLDER_2);
 
         mEnv.beforeAsserts();
@@ -138,8 +136,6 @@
         mEnv.searchViewManager.isSearching = true;
         mEnv.docs.nextDocuments = Arrays.asList(TestEnv.FOLDER_1, TestEnv.FOLDER_2);
 
-        mEnv.state.stack.push(TestEnv.FOLDER_0);
-
         mHandler.openContainerDocument(TestEnv.FOLDER_2);
 
         mEnv.beforeAsserts();
@@ -150,6 +146,18 @@
     }
 
     @Test
+    public void testOpensDocument_AssertionErrorIfAlreadyInStack() throws Exception {
+        mEnv.populateStack();
+        boolean threw = false;
+        try {
+            mEnv.state.stack.push(TestEnv.FOLDER_0);
+        } catch (AssertionError e) {
+            threw = true;
+        }
+        assertTrue(threw);
+    }
+
+    @Test
     public void testLaunchToDocuments() throws Exception {
         mEnv.docs.nextIsDocumentsUri = true;
         mEnv.docs.nextPath = new Path(
diff --git a/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java b/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
index 820b6ce..db9e868 100644
--- a/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
+++ b/tests/unit/com/android/documentsui/archives/ArchivesProviderTest.java
@@ -116,7 +116,7 @@
             });
         }
 
-        latch.await(3, TimeUnit.SECONDS);
+        latch.await(30, TimeUnit.SECONDS);
         {
             final Cursor cursor = resolver.query(childrenUri, null, null, null, null, null);
             assertNotNull("Cursor must not be null. File not found?", cursor);
@@ -168,7 +168,7 @@
             });
         }
 
-        latch.await(3, TimeUnit.SECONDS);
+        latch.await(30, TimeUnit.SECONDS);
         {
             final Cursor cursor = resolver.query(childrenUri, null, null, null, null, null);
             assertNotNull("Cursor must not be null. File not found?", cursor);
diff --git a/tests/unit/com/android/documentsui/dirlist/AccessibilityTest.java b/tests/unit/com/android/documentsui/dirlist/AccessibilityTest.java
new file mode 100644
index 0000000..6ac0be8
--- /dev/null
+++ b/tests/unit/com/android/documentsui/dirlist/AccessibilityTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2017 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.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.test.AndroidTestCase;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import com.android.documentsui.testing.TestRecyclerView;
+import com.android.documentsui.testing.Views;
+
+import java.util.List;
+
+@SmallTest
+public class AccessibilityTest extends AndroidTestCase {
+
+    private static final List<String> ITEMS = TestData.create(10);
+
+    private TestRecyclerView mView;
+    private AccessibilityEventRouter mAccessibilityDelegate;
+    private boolean mCallbackCalled = false;
+
+    @Override
+    public void setUp() throws Exception {
+        mView = TestRecyclerView.create(ITEMS);
+        mAccessibilityDelegate = new AccessibilityEventRouter(mView, (View v) -> {
+            mCallbackCalled = true;
+            return true;
+        });
+        mView.setAccessibilityDelegateCompat(mAccessibilityDelegate);
+    }
+
+    public void test_announceSelected() throws Exception {
+        View item = Views.createTestView(true);
+        AccessibilityNodeInfoCompat info = new AccessibilityNodeInfoCompat(AccessibilityNodeInfo.obtain());
+        mAccessibilityDelegate.getItemDelegate().onInitializeAccessibilityNodeInfo(item, info);
+        assertTrue(info.isSelected());
+    }
+
+    public void test_routesAccessibilityClicks() throws Exception {
+        View item = Views.createTestView(true);
+        AccessibilityNodeInfoCompat info = new AccessibilityNodeInfoCompat(AccessibilityNodeInfo.obtain());
+        mAccessibilityDelegate.getItemDelegate().onInitializeAccessibilityNodeInfo(item, info);
+        mAccessibilityDelegate.getItemDelegate().performAccessibilityAction(item, AccessibilityNodeInfoCompat.ACTION_CLICK, null);
+        assertTrue(mCallbackCalled);
+    }
+}