Merge "Remove expand/collapse animations in the Call Log."
diff --git a/Android.mk b/Android.mk
index 696caf1..b029189 100644
--- a/Android.mk
+++ b/Android.mk
@@ -22,19 +22,21 @@
 
 LOCAL_AAPT_FLAGS := \
     --auto-add-overlay \
+    --extra-packages android.support.v7.recyclerview \
     --extra-packages com.android.incallui \
     --extra-packages com.android.contacts.common \
     --extra-packages com.android.phone.common
 
 LOCAL_JAVA_LIBRARIES := telephony-common
 LOCAL_STATIC_JAVA_LIBRARIES := \
-    com.android.services.telephony.common \
-    com.android.vcard \
     android-common \
-    guava \
+    android-ex-variablespeed \
     android-support-v13 \
     android-support-v4 \
-    android-ex-variablespeed \
+    android-support-v7-recyclerview \
+    com.android.services.telephony.common \
+    com.android.vcard \
+    guava \
     libphonenumber
 
 LOCAL_REQUIRED_MODULES := libvariablespeed
diff --git a/src/com/android/dialer/calllog/CallLogAdapter.java b/src/com/android/dialer/calllog/CallLogAdapter.java
index 78da6e8..914e7ba 100644
--- a/src/com/android/dialer/calllog/CallLogAdapter.java
+++ b/src/com/android/dialer/calllog/CallLogAdapter.java
@@ -36,7 +36,6 @@
 import android.widget.ImageView;
 import android.widget.TextView;
 
-import com.android.common.widget.GroupingListAdapter;
 import com.android.contacts.common.util.UriUtils;
 import com.android.dialer.PhoneCallDetails;
 import com.android.dialer.PhoneCallDetailsHelper;
diff --git a/src/com/android/dialer/calllog/CallLogFragment.java b/src/com/android/dialer/calllog/CallLogFragment.java
index d6fb456..904b9c5 100644
--- a/src/com/android/dialer/calllog/CallLogFragment.java
+++ b/src/com/android/dialer/calllog/CallLogFragment.java
@@ -86,6 +86,7 @@
 
     private VoicemailStatusHelper mVoicemailStatusHelper;
     private View mStatusMessageView;
+    private View mEmptyListView;
     private TextView mStatusMessageText;
     private TextView mStatusMessageAction;
     private KeyguardManager mKeyguardManager;
@@ -205,8 +206,13 @@
         mAdapter.changeCursor(cursor);
         // This will update the state of the "Clear call log" menu item.
         getActivity().invalidateOptionsMenu();
+
+        final ListView listView = getListView();
+        boolean showListView = cursor.getCount() > 0;
+        listView.setVisibility(showListView ? View.VISIBLE : View.GONE);
+        mEmptyListView.setVisibility(!showListView ? View.VISIBLE : View.GONE);
+
         if (mScrollToTop) {
-            final ListView listView = getListView();
             // The smooth-scroll animation happens over a fixed time period.
             // As a result, if it scrolls through a large portion of the list,
             // each frame will jump so far from the previous one that the user
@@ -279,7 +285,7 @@
     @Override
     public void onViewCreated(View view, Bundle savedInstanceState) {
         super.onViewCreated(view, savedInstanceState);
-        getListView().setEmptyView(view.findViewById(R.id.empty_list_view));
+        mEmptyListView = view.findViewById(R.id.empty_list_view);
         getListView().setItemsCanFocus(true);
         maybeAddFooterView();
 
@@ -399,7 +405,7 @@
                         + filterType);
         }
         DialerUtils.configureEmptyListView(
-                getListView().getEmptyView(), R.drawable.empty_call_log, messageId, getResources());
+                mEmptyListView, R.drawable.empty_call_log, messageId, getResources());
     }
 
     CallLogAdapter getAdapter() {
diff --git a/src/com/android/dialer/calllog/CallLogGroupBuilder.java b/src/com/android/dialer/calllog/CallLogGroupBuilder.java
index 1f11e1e..0826aeb 100644
--- a/src/com/android/dialer/calllog/CallLogGroupBuilder.java
+++ b/src/com/android/dialer/calllog/CallLogGroupBuilder.java
@@ -21,7 +21,6 @@
 import android.telephony.PhoneNumberUtils;
 import android.text.format.Time;
 
-import com.android.common.widget.GroupingListAdapter;
 import com.android.contacts.common.util.DateUtils;
 import com.android.contacts.common.util.PhoneNumberHelper;
 
diff --git a/src/com/android/dialer/calllog/GroupingListAdapter.java b/src/com/android/dialer/calllog/GroupingListAdapter.java
new file mode 100644
index 0000000..7895549
--- /dev/null
+++ b/src/com/android/dialer/calllog/GroupingListAdapter.java
@@ -0,0 +1,490 @@
+/*
+ * Copyright (C) 2015 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.dialer.calllog;
+
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.DataSetObserver;
+import android.os.Handler;
+import android.util.SparseIntArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import com.android.contacts.common.testing.NeededForTesting;
+
+/**
+ * Maintains a list that groups adjacent items sharing the same value of a "group-by" field.
+ *
+ * The list has three types of elements: stand-alone, group header and group child. Groups are
+ * collapsible and collapsed by default. This is used by the call log to group related entries.
+ */
+abstract class GroupingListAdapter extends BaseAdapter {
+
+    private static final int GROUP_METADATA_ARRAY_INITIAL_SIZE = 16;
+    private static final int GROUP_METADATA_ARRAY_INCREMENT = 128;
+    private static final long GROUP_OFFSET_MASK    = 0x00000000FFFFFFFFL;
+    private static final long GROUP_SIZE_MASK     = 0x7FFFFFFF00000000L;
+    private static final long EXPANDED_GROUP_MASK = 0x8000000000000000L;
+
+    public static final int ITEM_TYPE_STANDALONE = 0;
+    public static final int ITEM_TYPE_GROUP_HEADER = 1;
+    public static final int ITEM_TYPE_IN_GROUP = 2;
+
+    /**
+     * Information about a specific list item: is it a group, if so is it expanded.
+     * Otherwise, is it a stand-alone item or a group member.
+     */
+    protected static class PositionMetadata {
+        int itemType;
+        boolean isExpanded;
+        int cursorPosition;
+        int childCount;
+        private int groupPosition;
+        private int listPosition = -1;
+    }
+
+    private Context mContext;
+    private Cursor mCursor;
+
+    /**
+     * Count of list items.
+     */
+    private int mCount;
+
+    private int mRowIdColumnIndex;
+
+    /**
+     * Count of groups in the list.
+     */
+    private int mGroupCount;
+
+    /**
+     * Information about where these groups are located in the list, how large they are
+     * and whether they are expanded.
+     */
+    private long[] mGroupMetadata;
+
+    private SparseIntArray mPositionCache = new SparseIntArray();
+    private int mLastCachedListPosition;
+    private int mLastCachedCursorPosition;
+    private int mLastCachedGroup;
+
+    /**
+     * A reusable temporary instance of PositionMetadata
+     */
+    private PositionMetadata mPositionMetadata = new PositionMetadata();
+
+    protected ContentObserver mChangeObserver = new ContentObserver(new Handler()) {
+
+        @Override
+        public boolean deliverSelfNotifications() {
+            return true;
+        }
+
+        @Override
+        public void onChange(boolean selfChange) {
+            onContentChanged();
+        }
+    };
+
+    protected DataSetObserver mDataSetObserver = new DataSetObserver() {
+
+        @Override
+        public void onChanged() {
+            notifyDataSetChanged();
+        }
+
+        @Override
+        public void onInvalidated() {
+            notifyDataSetInvalidated();
+        }
+    };
+
+    public GroupingListAdapter(Context context) {
+        mContext = context;
+        resetCache();
+    }
+
+    /**
+     * Finds all groups of adjacent items in the cursor and calls {@link #addGroup} for
+     * each of them.
+     */
+    protected abstract void addGroups(Cursor cursor);
+
+    protected abstract View newStandAloneView(Context context, ViewGroup parent);
+    protected abstract void bindStandAloneView(View view, Context context, Cursor cursor);
+
+    protected abstract View newGroupView(Context context, ViewGroup parent);
+    protected abstract void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
+            boolean expanded);
+
+    protected abstract View newChildView(Context context, ViewGroup parent);
+    protected abstract void bindChildView(View view, Context context, Cursor cursor);
+
+    /**
+     * Cache should be reset whenever the cursor changes or groups are expanded or collapsed.
+     */
+    private void resetCache() {
+        mCount = -1;
+        mLastCachedListPosition = -1;
+        mLastCachedCursorPosition = -1;
+        mLastCachedGroup = -1;
+        mPositionMetadata.listPosition = -1;
+        mPositionCache.clear();
+    }
+
+    protected void onContentChanged() {
+    }
+
+    public void changeCursor(Cursor cursor) {
+        if (cursor == mCursor) {
+            return;
+        }
+
+        if (mCursor != null) {
+            mCursor.unregisterContentObserver(mChangeObserver);
+            mCursor.unregisterDataSetObserver(mDataSetObserver);
+            mCursor.close();
+        }
+        mCursor = cursor;
+        resetCache();
+        findGroups();
+
+        if (cursor != null) {
+            cursor.registerContentObserver(mChangeObserver);
+            cursor.registerDataSetObserver(mDataSetObserver);
+            mRowIdColumnIndex = cursor.getColumnIndexOrThrow("_id");
+            notifyDataSetChanged();
+        } else {
+            // notify the observers about the lack of a data set
+            notifyDataSetInvalidated();
+        }
+
+    }
+
+    public Cursor getCursor() {
+        return mCursor;
+    }
+
+    /**
+     * Scans over the entire cursor looking for duplicate phone numbers that need
+     * to be collapsed.
+     */
+    private void findGroups() {
+        mGroupCount = 0;
+        mGroupMetadata = new long[GROUP_METADATA_ARRAY_INITIAL_SIZE];
+
+        if (mCursor == null) {
+            return;
+        }
+
+        addGroups(mCursor);
+    }
+
+    /**
+     * Records information about grouping in the list.  Should be called by the overridden
+     * {@link #addGroups} method.
+     */
+    protected void addGroup(int cursorPosition, int size, boolean expanded) {
+        if (mGroupCount >= mGroupMetadata.length) {
+            int newSize = idealLongArraySize(
+                    mGroupMetadata.length + GROUP_METADATA_ARRAY_INCREMENT);
+            long[] array = new long[newSize];
+            System.arraycopy(mGroupMetadata, 0, array, 0, mGroupCount);
+            mGroupMetadata = array;
+        }
+
+        long metadata = ((long)size << 32) | cursorPosition;
+        if (expanded) {
+            metadata |= EXPANDED_GROUP_MASK;
+        }
+        mGroupMetadata[mGroupCount++] = metadata;
+    }
+
+    // Copy/paste from ArrayUtils
+    private int idealLongArraySize(int need) {
+        return idealByteArraySize(need * 8) / 8;
+    }
+
+    // Copy/paste from ArrayUtils
+    private int idealByteArraySize(int need) {
+        for (int i = 4; i < 32; i++)
+            if (need <= (1 << i) - 12)
+                return (1 << i) - 12;
+
+        return need;
+    }
+
+    public int getCount() {
+        if (mCursor == null) {
+            return 0;
+        }
+
+        if (mCount != -1) {
+            return mCount;
+        }
+
+        int cursorPosition = 0;
+        int count = 0;
+        for (int i = 0; i < mGroupCount; i++) {
+            long metadata = mGroupMetadata[i];
+            int offset = (int)(metadata & GROUP_OFFSET_MASK);
+            boolean expanded = (metadata & EXPANDED_GROUP_MASK) != 0;
+            int size = (int)((metadata & GROUP_SIZE_MASK) >> 32);
+
+            count += (offset - cursorPosition);
+
+            if (expanded) {
+                count += size + 1;
+            } else {
+                count++;
+            }
+
+            cursorPosition = offset + size;
+        }
+
+        mCount = count + mCursor.getCount() - cursorPosition;
+        return mCount;
+    }
+
+    /**
+     * Figures out whether the item at the specified position represents a
+     * stand-alone element, a group or a group child. Also computes the
+     * corresponding cursor position.
+     */
+    public void obtainPositionMetadata(PositionMetadata metadata, int position) {
+
+        // If the description object already contains requested information, just return
+        if (metadata.listPosition == position) {
+            return;
+        }
+
+        int listPosition = 0;
+        int cursorPosition = 0;
+        int firstGroupToCheck = 0;
+
+        // Check cache for the supplied position.  What we are looking for is
+        // the group descriptor immediately preceding the supplied position.
+        // Once we have that, we will be able to tell whether the position
+        // is the header of the group, a member of the group or a standalone item.
+        if (mLastCachedListPosition != -1) {
+            if (position <= mLastCachedListPosition) {
+
+                // Have SparceIntArray do a binary search for us.
+                int index = mPositionCache.indexOfKey(position);
+
+                // If we get back a positive number, the position corresponds to
+                // a group header.
+                if (index < 0) {
+
+                    // We had a cache miss, but we did obtain valuable information anyway.
+                    // The negative number will allow us to compute the location of
+                    // the group header immediately preceding the supplied position.
+                    index = ~index - 1;
+
+                    if (index >= mPositionCache.size()) {
+                        index--;
+                    }
+                }
+
+                // A non-negative index gives us the position of the group header
+                // corresponding or preceding the position, so we can
+                // search for the group information at the supplied position
+                // starting with the cached group we just found
+                if (index >= 0) {
+                    listPosition = mPositionCache.keyAt(index);
+                    firstGroupToCheck = mPositionCache.valueAt(index);
+                    long descriptor = mGroupMetadata[firstGroupToCheck];
+                    cursorPosition = (int)(descriptor & GROUP_OFFSET_MASK);
+                }
+            } else {
+
+                // If we haven't examined groups beyond the supplied position,
+                // we will start where we left off previously
+                firstGroupToCheck = mLastCachedGroup;
+                listPosition = mLastCachedListPosition;
+                cursorPosition = mLastCachedCursorPosition;
+            }
+        }
+
+        for (int i = firstGroupToCheck; i < mGroupCount; i++) {
+            long group = mGroupMetadata[i];
+            int offset = (int)(group & GROUP_OFFSET_MASK);
+
+            // Move pointers to the beginning of the group
+            listPosition += (offset - cursorPosition);
+            cursorPosition = offset;
+
+            if (i > mLastCachedGroup) {
+                mPositionCache.append(listPosition, i);
+                mLastCachedListPosition = listPosition;
+                mLastCachedCursorPosition = cursorPosition;
+                mLastCachedGroup = i;
+            }
+
+            // Now we have several possibilities:
+            // A) The requested position precedes the group
+            if (position < listPosition) {
+                metadata.itemType = ITEM_TYPE_STANDALONE;
+                metadata.cursorPosition = cursorPosition - (listPosition - position);
+                return;
+            }
+
+            boolean expanded = (group & EXPANDED_GROUP_MASK) != 0;
+            int size = (int) ((group & GROUP_SIZE_MASK) >> 32);
+
+            // B) The requested position is a group header
+            if (position == listPosition) {
+                metadata.itemType = ITEM_TYPE_GROUP_HEADER;
+                metadata.groupPosition = i;
+                metadata.isExpanded = expanded;
+                metadata.childCount = size;
+                metadata.cursorPosition = offset;
+                return;
+            }
+
+            if (expanded) {
+                // C) The requested position is an element in the expanded group
+                if (position < listPosition + size + 1) {
+                    metadata.itemType = ITEM_TYPE_IN_GROUP;
+                    metadata.cursorPosition = cursorPosition + (position - listPosition) - 1;
+                    return;
+                }
+
+                // D) The element is past the expanded group
+                listPosition += size + 1;
+            } else {
+
+                // E) The element is past the collapsed group
+                listPosition++;
+            }
+
+            // Move cursor past the group
+            cursorPosition += size;
+        }
+
+        // The required item is past the last group
+        metadata.itemType = ITEM_TYPE_STANDALONE;
+        metadata.cursorPosition = cursorPosition + (position - listPosition);
+    }
+
+    /**
+     * Returns true if the specified position in the list corresponds to a
+     * group header.
+     */
+    public boolean isGroupHeader(int position) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        return mPositionMetadata.itemType == ITEM_TYPE_GROUP_HEADER;
+    }
+
+    /**
+     * Given a position of a groups header in the list, returns the size of
+     * the corresponding group.
+     */
+    public int getGroupSize(int position) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        return mPositionMetadata.childCount;
+    }
+
+    /**
+     * Mark group as expanded if it is collapsed and vice versa.
+     */
+    @NeededForTesting
+    public void toggleGroup(int position) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        if (mPositionMetadata.itemType != ITEM_TYPE_GROUP_HEADER) {
+            throw new IllegalArgumentException("Not a group at position " + position);
+        }
+
+        if (mPositionMetadata.isExpanded) {
+            mGroupMetadata[mPositionMetadata.groupPosition] &= ~EXPANDED_GROUP_MASK;
+        } else {
+            mGroupMetadata[mPositionMetadata.groupPosition] |= EXPANDED_GROUP_MASK;
+        }
+        resetCache();
+        notifyDataSetChanged();
+    }
+
+    @Override
+    public int getViewTypeCount() {
+        return 3;
+    }
+
+    @Override
+    public int getItemViewType(int position) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        return mPositionMetadata.itemType;
+    }
+
+    public Object getItem(int position) {
+        if (mCursor == null) {
+            return null;
+        }
+
+        obtainPositionMetadata(mPositionMetadata, position);
+        if (mCursor.moveToPosition(mPositionMetadata.cursorPosition)) {
+            return mCursor;
+        } else {
+            return null;
+        }
+    }
+
+    public long getItemId(int position) {
+        Object item = getItem(position);
+        if (item != null) {
+            return mCursor.getLong(mRowIdColumnIndex);
+        } else {
+            return -1;
+        }
+    }
+
+    public View getView(int position, View convertView, ViewGroup parent) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        View view = convertView;
+        if (view == null) {
+            switch (mPositionMetadata.itemType) {
+                case ITEM_TYPE_STANDALONE:
+                    view = newStandAloneView(mContext, parent);
+                    break;
+                case ITEM_TYPE_GROUP_HEADER:
+                    view = newGroupView(mContext, parent);
+                    break;
+                case ITEM_TYPE_IN_GROUP:
+                    view = newChildView(mContext, parent);
+                    break;
+            }
+        }
+
+        mCursor.moveToPosition(mPositionMetadata.cursorPosition);
+        switch (mPositionMetadata.itemType) {
+            case ITEM_TYPE_STANDALONE:
+                bindStandAloneView(view, mContext, mCursor);
+                break;
+            case ITEM_TYPE_GROUP_HEADER:
+                bindGroupView(view, mContext, mCursor, mPositionMetadata.childCount,
+                        mPositionMetadata.isExpanded);
+                break;
+            case ITEM_TYPE_IN_GROUP:
+                bindChildView(view, mContext, mCursor);
+                break;
+
+        }
+        return view;
+    }
+}
diff --git a/tests/src/com/android/dialer/calllog/GroupingListAdapterTests.java b/tests/src/com/android/dialer/calllog/GroupingListAdapterTests.java
new file mode 100644
index 0000000..3eb5f06
--- /dev/null
+++ b/tests/src/com/android/dialer/calllog/GroupingListAdapterTests.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright (C) 2015 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.dialer.calllog;
+
+import static com.android.dialer.calllog.GroupingListAdapter.ITEM_TYPE_GROUP_HEADER;
+import static com.android.dialer.calllog.GroupingListAdapter.ITEM_TYPE_IN_GROUP;
+import static com.android.dialer.calllog.GroupingListAdapter.ITEM_TYPE_STANDALONE;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.test.AndroidTestCase;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * Tests for {@link GroupingListAdapter}.
+ *
+ * Running all tests:
+ *
+ *   adb shell am instrument -e class com.android.dialer.calllog.GroupingListAdapterTests \
+ *     -w com.google.android.dialer.tests/android.test.InstrumentationTestRunner
+ */
+public class GroupingListAdapterTests extends AndroidTestCase {
+
+    static private final String[] PROJECTION = new String[] {
+        "_id",
+        "group",
+    };
+
+    private static final int GROUPING_COLUMN_INDEX = 1;
+
+    private MatrixCursor mCursor;
+    private long mNextId;
+
+    private GroupingListAdapter mAdapter = new GroupingListAdapter(null) {
+
+        @Override
+        protected void addGroups(Cursor cursor) {
+            int count = cursor.getCount();
+            int groupItemCount = 1;
+            cursor.moveToFirst();
+            String currentValue = cursor.getString(GROUPING_COLUMN_INDEX);
+            for (int i = 1; i < count; i++) {
+                cursor.moveToNext();
+                String value = cursor.getString(GROUPING_COLUMN_INDEX);
+                if (TextUtils.equals(value, currentValue)) {
+                    groupItemCount++;
+                } else {
+                    if (groupItemCount > 1) {
+                        addGroup(i - groupItemCount, groupItemCount, false);
+                    }
+
+                    groupItemCount = 1;
+                    currentValue = value;
+                }
+            }
+            if (groupItemCount > 1) {
+                addGroup(count - groupItemCount, groupItemCount, false);
+            }
+        }
+
+        @Override
+        protected void bindChildView(View view, Context context, Cursor cursor) {
+        }
+
+        @Override
+        protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
+                boolean expanded) {
+        }
+
+        @Override
+        protected void bindStandAloneView(View view, Context context, Cursor cursor) {
+        }
+
+        @Override
+        protected View newChildView(Context context, ViewGroup parent) {
+            return null;
+        }
+
+        @Override
+        protected View newGroupView(Context context, ViewGroup parent) {
+            return null;
+        }
+
+        @Override
+        protected View newStandAloneView(Context context, ViewGroup parent) {
+            return null;
+        }
+    };
+
+    private void buildCursor(String... numbers) {
+        mCursor = new MatrixCursor(PROJECTION);
+        mNextId = 1;
+        for (String number : numbers) {
+            mCursor.addRow(new Object[]{mNextId, number});
+            mNextId++;
+        }
+    }
+
+    public void testGroupingWithoutGroups() {
+        buildCursor("1", "2", "3");
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(3, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_STANDALONE, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_STANDALONE, false, 2);
+    }
+
+    public void testGroupingWithCollapsedGroupAtTheBeginning() {
+        buildCursor("1", "1", "2");
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(2, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_GROUP_HEADER, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_STANDALONE, false, 2);
+    }
+
+    public void testGroupingWithExpandedGroupAtTheBeginning() {
+        buildCursor("1", "1", "2");
+        mAdapter.changeCursor(mCursor);
+        mAdapter.toggleGroup(0);
+
+        assertEquals(4, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_GROUP_HEADER, true, 0);
+        assertPositionMetadata(1, ITEM_TYPE_IN_GROUP, false, 0);
+        assertPositionMetadata(2, ITEM_TYPE_IN_GROUP, false, 1);
+        assertPositionMetadata(3, ITEM_TYPE_STANDALONE, false, 2);
+    }
+
+    public void testGroupingWithExpandCollapseCycleAtTheBeginning() {
+        buildCursor("1", "1", "2");
+        mAdapter.changeCursor(mCursor);
+        mAdapter.toggleGroup(0);
+        mAdapter.toggleGroup(0);
+
+        assertEquals(2, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_GROUP_HEADER, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_STANDALONE, false, 2);
+    }
+
+    public void testGroupingWithCollapsedGroupInTheMiddle() {
+        buildCursor("1", "2", "2", "2", "3");
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(3, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_STANDALONE, false, 4);
+    }
+
+    public void testGroupingWithExpandedGroupInTheMiddle() {
+        buildCursor("1", "2", "2", "2", "3");
+        mAdapter.changeCursor(mCursor);
+        mAdapter.toggleGroup(1);
+
+        assertEquals(6, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, true, 1);
+        assertPositionMetadata(2, ITEM_TYPE_IN_GROUP, false, 1);
+        assertPositionMetadata(3, ITEM_TYPE_IN_GROUP, false, 2);
+        assertPositionMetadata(4, ITEM_TYPE_IN_GROUP, false, 3);
+        assertPositionMetadata(5, ITEM_TYPE_STANDALONE, false, 4);
+    }
+
+    public void testGroupingWithCollapsedGroupAtTheEnd() {
+        buildCursor("1", "2", "3", "3", "3");
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(3, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_STANDALONE, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_GROUP_HEADER, false, 2);
+    }
+
+    public void testGroupingWithExpandedGroupAtTheEnd() {
+        buildCursor("1", "2", "3", "3", "3");
+        mAdapter.changeCursor(mCursor);
+        mAdapter.toggleGroup(2);
+
+        assertEquals(6, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_STANDALONE, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_GROUP_HEADER, true, 2);
+        assertPositionMetadata(3, ITEM_TYPE_IN_GROUP, false, 2);
+        assertPositionMetadata(4, ITEM_TYPE_IN_GROUP, false, 3);
+        assertPositionMetadata(5, ITEM_TYPE_IN_GROUP, false, 4);
+    }
+
+    public void testGroupingWithMultipleCollapsedGroups() {
+        buildCursor("1", "2", "2", "3", "4", "4", "5", "5", "6");
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(6, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(3, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(4, ITEM_TYPE_GROUP_HEADER, false, 6);
+        assertPositionMetadata(5, ITEM_TYPE_STANDALONE, false, 8);
+    }
+
+    public void testGroupingWithMultipleExpandedGroups() {
+        buildCursor("1", "2", "2", "3", "4", "4", "5", "5", "6");
+        mAdapter.changeCursor(mCursor);
+        mAdapter.toggleGroup(1);
+
+        // Note that expanding the group of 2's shifted the group of 5's down from the
+        // 4th to the 6th position
+        mAdapter.toggleGroup(6);
+
+        assertEquals(10, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, true, 1);
+        assertPositionMetadata(2, ITEM_TYPE_IN_GROUP, false, 1);
+        assertPositionMetadata(3, ITEM_TYPE_IN_GROUP, false, 2);
+        assertPositionMetadata(4, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(5, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(6, ITEM_TYPE_GROUP_HEADER, true, 6);
+        assertPositionMetadata(7, ITEM_TYPE_IN_GROUP, false, 6);
+        assertPositionMetadata(8, ITEM_TYPE_IN_GROUP, false, 7);
+        assertPositionMetadata(9, ITEM_TYPE_STANDALONE, false, 8);
+    }
+
+    public void testPositionCache() {
+        buildCursor("1", "2", "2", "3", "4", "4", "5", "5", "6");
+        mAdapter.changeCursor(mCursor);
+
+        // First pass - building up cache
+        assertEquals(6, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(3, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(4, ITEM_TYPE_GROUP_HEADER, false, 6);
+        assertPositionMetadata(5, ITEM_TYPE_STANDALONE, false, 8);
+
+        // Second pass - using cache
+        assertEquals(6, mAdapter.getCount());
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, false, 1);
+        assertPositionMetadata(2, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(3, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(4, ITEM_TYPE_GROUP_HEADER, false, 6);
+        assertPositionMetadata(5, ITEM_TYPE_STANDALONE, false, 8);
+
+        // Invalidate cache by expanding a group
+        mAdapter.toggleGroup(1);
+
+        // First pass - building up cache
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, true, 1);
+        assertPositionMetadata(2, ITEM_TYPE_IN_GROUP, false, 1);
+        assertPositionMetadata(3, ITEM_TYPE_IN_GROUP, false, 2);
+        assertPositionMetadata(4, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(5, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(6, ITEM_TYPE_GROUP_HEADER, false, 6);
+        assertPositionMetadata(7, ITEM_TYPE_STANDALONE, false, 8);
+
+        // Second pass - using cache
+        assertPositionMetadata(0, ITEM_TYPE_STANDALONE, false, 0);
+        assertPositionMetadata(1, ITEM_TYPE_GROUP_HEADER, true, 1);
+        assertPositionMetadata(2, ITEM_TYPE_IN_GROUP, false, 1);
+        assertPositionMetadata(3, ITEM_TYPE_IN_GROUP, false, 2);
+        assertPositionMetadata(4, ITEM_TYPE_STANDALONE, false, 3);
+        assertPositionMetadata(5, ITEM_TYPE_GROUP_HEADER, false, 4);
+        assertPositionMetadata(6, ITEM_TYPE_GROUP_HEADER, false, 6);
+        assertPositionMetadata(7, ITEM_TYPE_STANDALONE, false, 8);
+    }
+
+    public void testGroupDescriptorArrayGrowth() {
+        String[] numbers = new String[500];
+        for (int i = 0; i < numbers.length; i++) {
+
+            // Make groups of 2
+            numbers[i] = String.valueOf((i / 2) * 2);
+        }
+
+        buildCursor(numbers);
+        mAdapter.changeCursor(mCursor);
+
+        assertEquals(250, mAdapter.getCount());
+    }
+
+    private void assertPositionMetadata(int position, int itemType, boolean isExpanded,
+            int cursorPosition) {
+        GroupingListAdapter.PositionMetadata metadata = new GroupingListAdapter.PositionMetadata();
+        mAdapter.obtainPositionMetadata(metadata, position);
+        assertEquals(itemType, metadata.itemType);
+        if (metadata.itemType == ITEM_TYPE_GROUP_HEADER) {
+            assertEquals(isExpanded, metadata.isExpanded);
+        }
+        assertEquals(cursorPosition, metadata.cursorPosition);
+    }
+}