Suppress auto-closing drawer and add ripple effect on spring load roots.

Bug: 28865182
Change-Id: Ief7967e33b9a0d7e94a667172121d8007f78115b
diff --git a/res/drawable/root_item_background.xml b/res/drawable/root_item_background.xml
new file mode 100644
index 0000000..cc56f1e
--- /dev/null
+++ b/res/drawable/root_item_background.xml
@@ -0,0 +1,35 @@
+<?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.
+-->
+
+<ripple
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res/com.android.documentsui"
+    android:color="?attr/colorControlHighlight">
+    <item
+        android:id="@android:id/mask"
+        android:drawable="@android:color/white"/>
+
+    <item>
+        <selector>
+            <item
+                app:state_highlighted="true"
+                android:drawable="@color/item_doc_background_selected"/>
+            <item
+                app:state_highlighted="false"
+                android:drawable="@android:color/transparent"/>
+        </selector>
+    </item>
+</ripple>
\ No newline at end of file
diff --git a/res/layout/fragment_roots.xml b/res/layout/fragment_roots.xml
index b33b8d0..ae46207 100644
--- a/res/layout/fragment_roots.xml
+++ b/res/layout/fragment_roots.xml
@@ -19,5 +19,6 @@
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:paddingTop="8dp"
+    android:listSelector="@android:color/transparent"
     android:drawSelectorOnTop="true"
     android:divider="@null" />
diff --git a/res/layout/item_root.xml b/res/layout/item_root.xml
index 816cb8a..3e447c9 100644
--- a/res/layout/item_root.xml
+++ b/res/layout/item_root.xml
@@ -14,7 +14,8 @@
      limitations under the License.
 -->
 
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<com.android.documentsui.RootItemView
+    xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     android:minHeight="48dp"
@@ -22,7 +23,8 @@
     android:paddingEnd="@dimen/list_item_padding"
     android:gravity="center_vertical"
     android:orientation="horizontal"
-    android:baselineAligned="false">
+    android:baselineAligned="false"
+    android:background="@drawable/root_item_background">
 
     <FrameLayout
         android:layout_width="@dimen/icon_size"
@@ -68,4 +70,4 @@
 
     </LinearLayout>
 
-</LinearLayout>
+</com.android.documentsui.RootItemView>
diff --git a/res/values/attrs.xml b/res/values/attrs.xml
index 9e13001..b48c52f 100644
--- a/res/values/attrs.xml
+++ b/res/values/attrs.xml
@@ -17,4 +17,8 @@
     <declare-styleable name="DocumentsTheme">
         <attr name="colorActionMode" format="color"/>
     </declare-styleable>
+
+    <declare-styleable name="RootItemView">
+        <attr name="state_highlighted" format="boolean"/>
+    </declare-styleable>
 </resources>
diff --git a/res/values/tags.xml b/res/values/tags.xml
index 1c4b0ca..a7ff3d6 100644
--- a/res/values/tags.xml
+++ b/res/values/tags.xml
@@ -17,4 +17,5 @@
 <resources>
     <item name="drag_hovering_tag" type="id" />
     <item name="item_position_tag" type="id" />
+    <item name="layout_id_tag" type="id" />
 </resources>
\ No newline at end of file
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index 3597a74..d1285c8 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -256,8 +256,6 @@
         } else {
             new PickRootTask(this, root).executeOnExecutor(getExecutorForCurrentDirectory());
         }
-
-        mNavigator.revealRootsDrawer(false);
     }
 
     @Override
diff --git a/src/com/android/documentsui/ItemDragListener.java b/src/com/android/documentsui/ItemDragListener.java
index 2c018f8..66b94fc 100644
--- a/src/com/android/documentsui/ItemDragListener.java
+++ b/src/com/android/documentsui/ItemDragListener.java
@@ -17,6 +17,7 @@
 package com.android.documentsui;
 
 import android.content.ClipData;
+import android.graphics.drawable.Drawable;
 import android.util.Log;
 import android.view.DragEvent;
 import android.view.View;
@@ -62,6 +63,7 @@
                 handleEnteredEvent(v);
                 return true;
             case DragEvent.ACTION_DRAG_LOCATION:
+                handleLocationEvent(v, event.getX(), event.getY());
                 return true;
             case DragEvent.ACTION_DRAG_EXITED:
             case DragEvent.ACTION_DRAG_ENDED:
@@ -83,6 +85,13 @@
         mHoverTimer.schedule(task, ViewConfiguration.getLongPressTimeout());
     }
 
+    private void handleLocationEvent(View v, float x, float y) {
+        Drawable background = v.getBackground();
+        if (background != null) {
+            background.setHotspot(x, y);
+        }
+    }
+
     private void handleExitedEndedEvent(View v) {
         mDragHost.setDropTargetHighlight(v, false);
 
diff --git a/src/com/android/documentsui/RootItemView.java b/src/com/android/documentsui/RootItemView.java
new file mode 100644
index 0000000..93aa526
--- /dev/null
+++ b/src/com/android/documentsui/RootItemView.java
@@ -0,0 +1,55 @@
+/*
+ * 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.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+public final class RootItemView extends LinearLayout {
+    private static final int[] STATE_HIGHLIGHTED = {R.attr.state_highlighted};
+
+    private boolean mHighlighted = false;
+
+    public RootItemView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    @Override
+    public int[] onCreateDrawableState(int extraSpace) {
+        final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
+
+        if (mHighlighted) {
+            mergeDrawableStates(drawableState, STATE_HIGHLIGHTED);
+        }
+
+        return drawableState;
+    }
+
+    public void setHighlight(boolean highlight) {
+        mHighlighted = highlight;
+        refreshDrawableState();
+    }
+
+    /**
+     * Synthesizes pressed state to trick RippleDrawable starting a ripple effect.
+     */
+    public void drawRipple() {
+        setPressed(true);
+        setPressed(false);
+    }
+}
diff --git a/src/com/android/documentsui/RootsFragment.java b/src/com/android/documentsui/RootsFragment.java
index ad2ee07..b333379 100644
--- a/src/com/android/documentsui/RootsFragment.java
+++ b/src/com/android/documentsui/RootsFragment.java
@@ -18,6 +18,7 @@
 
 import static com.android.documentsui.Shared.DEBUG;
 
+import android.annotation.LayoutRes;
 import android.app.Activity;
 import android.app.Fragment;
 import android.app.FragmentManager;
@@ -26,12 +27,13 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.Loader;
+import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.Looper;
 import android.provider.Settings;
-import android.support.annotation.ColorRes;
 import android.support.annotation.Nullable;
 import android.text.TextUtils;
 import android.text.format.Formatter;
@@ -54,7 +56,9 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 
 /**
@@ -198,6 +202,10 @@
      */
     @Override
     public void onViewHovered(View view) {
+        // SpacerView doesn't have DragListener so this view is guaranteed to be a RootItemView.
+        RootItemView itemView = (RootItemView) view;
+        itemView.drawRipple();
+
         final int position = (Integer) view.getTag(R.id.item_position_tag);
         final Item item = mAdapter.getItem(position);
         item.open(this);
@@ -205,10 +213,9 @@
 
     @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));
+        // SpacerView doesn't have DragListener so this view is guaranteed to be a RootItemView.
+        RootItemView itemView = (RootItemView) v;
+        itemView.setHighlight(highlight);
     }
 
     private OnItemClickListener mItemListener = new OnItemClickListener() {
@@ -216,6 +223,8 @@
         public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
             final Item item = mAdapter.getItem(position);
             item.open(RootsFragment.this);
+
+            ((BaseActivity) getActivity()).setRootsDrawerOpen(false);
         }
     };
 
@@ -223,32 +232,34 @@
         @Override
         public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
             final Item item = mAdapter.getItem(position);
-            if (item instanceof AppItem) {
-                showAppDetails(((AppItem) item).info);
-                return true;
-            } else {
-                return false;
-            }
+            return item.showAppDetails(RootsFragment.this);
         }
     };
 
     private static abstract class Item {
-        private final int mLayoutId;
+        private final @LayoutRes int mLayoutId;
+        private final String mStringId;
 
-        public Item(int layoutId) {
+        public Item(@LayoutRes int layoutId, String stringId) {
             mLayoutId = layoutId;
+            mStringId = stringId;
         }
 
         public View getView(View convertView, ViewGroup parent) {
-            // Disable recycling views because 1) it's very unlikely a view can be recycled here;
-            // 2) there is no easy way for us to know with which layout id the convertView was
-            // inflated; and 3) simplicity is much appreciated at this time.
-            convertView = LayoutInflater.from(parent.getContext())
+            if (convertView == null
+                    || (Integer) convertView.getTag(R.id.layout_id_tag) != mLayoutId) {
+                convertView = LayoutInflater.from(parent.getContext())
                         .inflate(mLayoutId, parent, false);
+            }
+            convertView.setTag(R.id.layout_id_tag, mLayoutId);
             bindView(convertView);
             return convertView;
         }
 
+        boolean showAppDetails(RootsFragment fragment) {
+            return false;
+        }
+
         abstract void bindView(View convertView);
 
         abstract boolean isDropTarget();
@@ -257,13 +268,23 @@
     }
 
     private static class RootItem extends Item {
+        private static final String STRING_ID_FORMAT = "RootItem{%s/%s}";
+
         public final RootInfo root;
 
         public RootItem(RootInfo root) {
-            super(R.layout.item_root);
+            super(R.layout.item_root, getStringId(root));
             this.root = root;
         }
 
+        private static String getStringId(RootInfo root) {
+            // Empty URI authority is invalid, so we can use empty string if root.authority is null.
+            // Directly passing null to String.format() will write "null" which can be a valid URI
+            // authority.
+            String authority = (root.authority == null ? "" : root.authority);
+            return String.format(STRING_ID_FORMAT, authority, root.rootId);
+        }
+
         @Override
         public void bindView(View convertView) {
             final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon);
@@ -291,7 +312,7 @@
         }
 
         @Override
-        public void open(RootsFragment fragment) {
+        void open(RootsFragment fragment) {
             BaseActivity activity = BaseActivity.get(fragment);
             Metrics.logRootVisited(fragment.getActivity(), root);
             activity.onRootPicked(root);
@@ -299,8 +320,11 @@
     }
 
     private static class SpacerItem extends Item {
+        private static final String STRING_ID = "SpacerItem";
+
         public SpacerItem() {
-            super(R.layout.item_root_spacer);
+            // Multiple spacer items can share the same string id as they're identical.
+            super(R.layout.item_root_spacer, STRING_ID);
         }
 
         @Override
@@ -314,19 +338,35 @@
         }
 
         @Override
-        public void open(RootsFragment fragment) {
+        void open(RootsFragment fragment) {
             if (DEBUG) Log.d(TAG, "Ignoring click/hover on spacer item.");
         }
     }
 
     private static class AppItem extends Item {
+        private static final String STRING_ID_FORMAT = "AppItem{%s/%s}";
+
         public final ResolveInfo info;
 
         public AppItem(ResolveInfo info) {
-            super(R.layout.item_root);
+            super(R.layout.item_root, getStringId(info));
             this.info = info;
         }
 
+        private static String getStringId(ResolveInfo info) {
+            ActivityInfo activityInfo = info.activityInfo;
+
+            String component = String.format(
+                    STRING_ID_FORMAT, activityInfo.applicationInfo.packageName, activityInfo.name);
+            return component;
+        }
+
+        @Override
+        boolean showAppDetails(RootsFragment fragment) {
+            fragment.showAppDetails(info);
+            return true;
+        }
+
         @Override
         void bindView(View convertView) {
             final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon);
@@ -348,7 +388,7 @@
         }
 
         @Override
-        public void open(RootsFragment fragment) {
+        void open(RootsFragment fragment) {
             DocumentsActivity activity = DocumentsActivity.get(fragment);
             Metrics.logAppVisited(fragment.getActivity(), info);
             activity.onAppPicked(info);
@@ -356,6 +396,9 @@
     }
 
     private static class RootsAdapter extends ArrayAdapter<Item> {
+        private static final Map<String, Long> sIdMap = new HashMap<String, Long>();
+        // the next available id to associate with a new string id
+        private static long sNextAvailableId;
 
         private OnDragListener mDragListener;
 
@@ -430,6 +473,30 @@
         }
 
         @Override
+        public boolean hasStableIds() {
+            return true;
+        }
+
+        @Override
+        public long getItemId(int position) {
+            // Ensure this method is only called in main thread because we don't have any
+            // concurrency protection.
+            assert(Looper.myLooper() == Looper.getMainLooper());
+
+            String stringId = getItem(position).mStringId;
+
+            long id;
+            if (sIdMap.containsKey(stringId)) {
+                id = sIdMap.get(stringId);
+            } else {
+                id = sNextAvailableId++;
+                sIdMap.put(stringId, id);
+            }
+
+            return id;
+        }
+
+        @Override
         public View getView(int position, View convertView, ViewGroup parent) {
             final Item item = getItem(position);
             final View view = item.getView(convertView, parent);
diff --git a/tests/src/com/android/documentsui/ItemDragListenerTest.java b/tests/src/com/android/documentsui/ItemDragListenerTest.java
index 924c99b..37f6532 100644
--- a/tests/src/com/android/documentsui/ItemDragListenerTest.java
+++ b/tests/src/com/android/documentsui/ItemDragListenerTest.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertSame;
@@ -29,6 +30,7 @@
 
 import com.android.documentsui.testing.ClipDatas;
 import com.android.documentsui.testing.DragEvents;
+import com.android.documentsui.testing.TestDrawable;
 import com.android.documentsui.testing.TestTimer;
 import com.android.documentsui.testing.Views;
 
@@ -46,6 +48,7 @@
     private static final long DELAY_AFTER_HOVERING = ItemDragListener.SPRING_TIMEOUT + 1;
 
     private View mTestView;
+    private TestDrawable mTestBackground;
     private TestDragHost mTestDragHost;
     private TestTimer mTestTimer;
 
@@ -54,9 +57,10 @@
     @Before
     public void setUp() {
         mTestView = Views.createTestView();
-
+        mTestBackground = new TestDrawable();
         mTestTimer = new TestTimer();
         mTestDragHost = new TestDragHost();
+
         mListener = new TestDragListener(mTestDragHost, mTestTimer);
     }
 
@@ -88,6 +92,25 @@
     }
 
     @Test
+    public void testDragLocation_notCrashWithoutBackground() {
+        DragEvent locationEvent = DragEvents.createTestLocationEvent(3, 4);
+        mListener.onDrag(mTestView, locationEvent);
+    }
+
+    @Test
+    public void testDragLocation_setHotSpotOnBackground() {
+        Views.setBackground(mTestView, mTestBackground);
+
+        final float x = 2;
+        final float y = 4;
+        DragEvent locationEvent = DragEvents.createTestLocationEvent(x, y);
+        mListener.onDrag(mTestView, locationEvent);
+
+        assertEquals(x, mTestBackground.hotspotX, 0);
+        assertEquals(y, mTestBackground.hotspotY, 0);
+    }
+
+    @Test
     public void testHover_OpensView() {
         triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED);
 
diff --git a/tests/src/com/android/documentsui/testing/DragEvents.java b/tests/src/com/android/documentsui/testing/DragEvents.java
index 1a009a4..4ad9ec0 100644
--- a/tests/src/com/android/documentsui/testing/DragEvents.java
+++ b/tests/src/com/android/documentsui/testing/DragEvents.java
@@ -32,6 +32,14 @@
         return mockEvent;
     }
 
+    public static DragEvent createTestLocationEvent(float x, float y) {
+        final DragEvent locationEvent = createTestDragEvent(DragEvent.ACTION_DRAG_LOCATION);
+        Mockito.when(locationEvent.getX()).thenReturn(x);
+        Mockito.when(locationEvent.getY()).thenReturn(y);
+
+        return locationEvent;
+    }
+
     public static DragEvent createTestDropEvent(ClipData clipData) {
         final DragEvent dropEvent = createTestDragEvent(DragEvent.ACTION_DROP);
         Mockito.when(dropEvent.getClipData()).thenReturn(clipData);
diff --git a/tests/src/com/android/documentsui/testing/TestDrawable.java b/tests/src/com/android/documentsui/testing/TestDrawable.java
new file mode 100644
index 0000000..bc3831e
--- /dev/null
+++ b/tests/src/com/android/documentsui/testing/TestDrawable.java
@@ -0,0 +1,53 @@
+/*
+ * 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.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.drawable.Drawable;
+
+public class TestDrawable extends Drawable {
+
+    public float hotspotX;
+    public float hotspotY;
+
+    @Override
+    public void setHotspot(float x, float y) {
+        hotspotX = x;
+        hotspotY = y;
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter colorFilter) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getOpacity() {
+        throw new UnsupportedOperationException();
+    }
+}
diff --git a/tests/src/com/android/documentsui/testing/Views.java b/tests/src/com/android/documentsui/testing/Views.java
index 15aa01b..52a9cbc 100644
--- a/tests/src/com/android/documentsui/testing/Views.java
+++ b/tests/src/com/android/documentsui/testing/Views.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui.testing;
 
+import android.graphics.drawable.Drawable;
 import android.view.View;
 
 import org.mockito.Mockito;
@@ -31,4 +32,8 @@
 
         return view;
     }
+
+    public static void setBackground(View testView, Drawable background) {
+        Mockito.when(testView.getBackground()).thenReturn(background);
+    }
 }