Adding support for "collapsibility" to the call log

Please note the optimizations for handling long call logs.

There is a change from the previous patch set: we now
break out from a group a bounced call if it is the latest
call in the group.

Bug: 2325659

Change-Id: I92e7dc25c2240f15f174391bf7b955c2596dbe30
diff --git a/res/drawable/call_background_secondary.xml b/res/drawable/call_background_secondary.xml
new file mode 100644
index 0000000..b784862
--- /dev/null
+++ b/res/drawable/call_background_secondary.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 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.
+-->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item android:state_window_focused="false"
+        android:drawable="@android:color/transparent" />
+    <item android:state_focused="false" android:state_pressed="true"
+        android:drawable="@*android:drawable/list_selector_background_transition" />
+    <item android:state_focused="false" android:state_pressed="false"
+        android:drawable="@color/background_secondary"/>
+
+</selector>
diff --git a/res/drawable/list_item_background_secondary.xml b/res/drawable/list_item_background_secondary.xml
new file mode 100644
index 0000000..0a27206
--- /dev/null
+++ b/res/drawable/list_item_background_secondary.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 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.
+-->
+
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:state_pressed="true" android:drawable="@android:color/transparent"/>
+    <item android:state_selected="true" android:drawable="@android:color/transparent"/>
+    <item android:drawable="@color/background_secondary"/> <!-- not selected -->
+</selector>
diff --git a/res/layout-finger/recent_calls_list_child_item.xml b/res/layout-finger/recent_calls_list_child_item.xml
new file mode 100644
index 0000000..14eb24d
--- /dev/null
+++ b/res/layout-finger/recent_calls_list_child_item.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 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.
+-->
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="?android:attr/listPreferredItemHeight"
+    android:paddingLeft="7dip"
+    android:background="@drawable/list_item_background_secondary"
+>
+
+    <com.android.contacts.ui.widget.DontPressWithParentImageView android:id="@+id/call_icon"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:paddingLeft="14dip"
+        android:paddingRight="14dip"
+        android:layout_alignParentRight="true"
+
+        android:gravity="center_vertical"
+        android:src="@android:drawable/sym_action_call"
+        android:background="@drawable/call_background_secondary"
+    />
+
+    <include layout="@layout/recent_calls_list_item_layout"/>
+
+</RelativeLayout>
diff --git a/res/layout-finger/recent_calls_list_group_item.xml b/res/layout-finger/recent_calls_list_group_item.xml
new file mode 100644
index 0000000..acaecaa
--- /dev/null
+++ b/res/layout-finger/recent_calls_list_group_item.xml
@@ -0,0 +1,123 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 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.
+-->
+
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="?android:attr/listPreferredItemHeight"
+    android:paddingLeft="7dip"
+>
+
+    <com.android.contacts.ui.widget.DontPressWithParentImageView android:id="@+id/call_icon"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:paddingLeft="14dip"
+        android:paddingRight="14dip"
+        android:layout_alignParentRight="true"
+
+        android:gravity="center_vertical"
+        android:src="@android:drawable/sym_action_call"
+        android:background="@drawable/call_background"
+    />
+
+    <View android:id="@+id/divider"
+        android:layout_width="1px"
+        android:layout_height="match_parent"
+        android:layout_marginTop="5dip"
+        android:layout_marginBottom="5dip"
+        android:layout_toLeftOf="@id/call_icon"
+        android:layout_marginLeft="11dip"
+        android:background="@drawable/divider_vertical_dark"
+    />
+
+    <TextView android:id="@+id/date"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toLeftOf="@id/divider"
+        android:layout_alignParentBottom="true"
+        android:layout_marginBottom="8dip"
+
+        android:textAppearance="?android:attr/textAppearanceSmall"
+        android:singleLine="true"
+    />
+
+    <TextView android:id="@+id/label"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentBottom="true"
+        android:layout_marginLeft="36dip"
+        android:layout_alignBaseline="@id/date"
+
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:textAppearance="?android:attr/textAppearanceSmall"
+        android:textStyle="bold"
+    />
+
+    <TextView android:id="@+id/number"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="5dip"
+        android:layout_toRightOf="@id/label"
+        android:layout_toLeftOf="@id/date"
+        android:layout_alignBaseline="@id/label"
+        android:layout_alignWithParentIfMissing="true"
+
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:textAppearance="?android:attr/textAppearanceSmall"
+    />
+
+    <TextView android:id="@+id/groupSize"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentTop="true"
+        android:layout_toLeftOf="@id/divider"
+        android:layout_above="@id/label"
+        android:layout_alignWithParentIfMissing="true"
+
+        android:textAppearance="?android:attr/textAppearanceLarge"
+        android:singleLine="true"
+        android:gravity="center_vertical"
+    />
+
+    <TextView android:id="@+id/line1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentTop="true"
+        android:layout_toLeftOf="@+id/groupSize"
+        android:layout_above="@id/date"
+        android:layout_alignWithParentIfMissing="true"
+        android:layout_marginLeft="36dip"
+        android:layout_marginBottom="-10dip"
+
+        android:textAppearance="?android:attr/textAppearanceLarge"
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:gravity="center_vertical"
+    />
+
+    <ImageView
+        android:id="@+id/groupIndicator"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_centerVertical="true"
+        android:src="@*android:drawable/expander_ic_minimized"
+        android:gravity="center_vertical"
+    />
+</RelativeLayout>
diff --git a/res/layout-finger/recent_calls_list_item.xml b/res/layout-finger/recent_calls_list_item.xml
index 00691c9..8efa23c 100644
--- a/res/layout-finger/recent_calls_list_item.xml
+++ b/res/layout-finger/recent_calls_list_item.xml
@@ -32,75 +32,6 @@
         android:background="@drawable/call_background"
     />
 
-    <View android:id="@+id/divider"
-        android:layout_width="1px"
-        android:layout_height="match_parent"
-        android:layout_marginTop="5dip"
-        android:layout_marginBottom="5dip"
-        android:layout_toLeftOf="@id/call_icon"
-        android:layout_marginLeft="11dip"
-        android:background="@drawable/divider_vertical_dark"
-    />
-
-    <ImageView android:id="@+id/call_type_icon"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_alignParentTop="true"
-        android:layout_toLeftOf="@id/divider"
-    />
-
-    <TextView android:id="@+id/date"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_toLeftOf="@id/divider"
-        android:layout_alignParentBottom="true"
-        android:layout_marginBottom="8dip"
-
-        android:textAppearance="?android:attr/textAppearanceSmall"
-        android:singleLine="true"
-    />
-
-    <TextView android:id="@+id/label"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_alignParentLeft="true"
-        android:layout_alignParentBottom="true"
-        android:layout_marginBottom="8dip"
-        android:layout_marginTop="-10dip"
-
-        android:singleLine="true"
-        android:ellipsize="marquee"
-        android:textAppearance="?android:attr/textAppearanceSmall"
-        android:textStyle="bold"
-    />
-
-    <TextView android:id="@+id/number"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginLeft="5dip"
-        android:layout_toRightOf="@id/label"
-        android:layout_toLeftOf="@id/date"
-        android:layout_alignBaseline="@id/label"
-        android:layout_alignWithParentIfMissing="true"
-
-        android:singleLine="true"
-        android:ellipsize="marquee"
-        android:textAppearance="?android:attr/textAppearanceSmall"
-    />
-
-    <TextView android:id="@+id/line1"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_alignParentLeft="true"
-        android:layout_alignParentTop="true"
-        android:layout_toLeftOf="@+id/call_type_icon"
-        android:layout_above="@id/label"
-        android:layout_alignWithParentIfMissing="true"
-
-        android:textAppearance="?android:attr/textAppearanceLarge"
-        android:singleLine="true"
-        android:ellipsize="marquee"
-        android:gravity="center_vertical"
-    />
+    <include layout="@layout/recent_calls_list_item_layout"/>
 
 </RelativeLayout>
diff --git a/res/layout-finger/recent_calls_list_item_layout.xml b/res/layout-finger/recent_calls_list_item_layout.xml
new file mode 100644
index 0000000..29ec59e
--- /dev/null
+++ b/res/layout-finger/recent_calls_list_item_layout.xml
@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 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.
+-->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <View android:id="@+id/divider"
+        android:layout_width="1px"
+        android:layout_height="match_parent"
+        android:layout_marginTop="5dip"
+        android:layout_marginBottom="5dip"
+        android:layout_toLeftOf="@id/call_icon"
+        android:layout_marginLeft="11dip"
+        android:background="@drawable/divider_vertical_dark"
+    />
+
+    <ImageView android:id="@+id/call_type_icon"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentLeft="true"
+        android:layout_marginLeft="4dip"
+    />
+
+    <TextView android:id="@+id/date"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_toLeftOf="@id/divider"
+        android:layout_alignParentBottom="true"
+        android:layout_marginBottom="8dip"
+
+        android:textAppearance="?android:attr/textAppearanceSmall"
+        android:singleLine="true"
+    />
+
+    <TextView android:id="@+id/label"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentBottom="true"
+        android:layout_marginLeft="36dip"
+        android:layout_alignBaseline="@id/date"
+
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:textAppearance="?android:attr/textAppearanceSmall"
+        android:textStyle="bold"
+    />
+
+    <TextView android:id="@+id/number"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginLeft="5dip"
+        android:layout_toRightOf="@id/label"
+        android:layout_toLeftOf="@id/date"
+        android:layout_alignBaseline="@id/label"
+        android:layout_alignWithParentIfMissing="true"
+
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:textAppearance="?android:attr/textAppearanceSmall"
+    />
+
+    <TextView android:id="@+id/line1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentTop="true"
+        android:layout_toLeftOf="@id/divider"
+        android:layout_above="@id/date"
+        android:layout_alignWithParentIfMissing="true"
+        android:layout_marginLeft="36dip"
+        android:layout_marginBottom="-10dip"
+
+        android:textAppearance="?android:attr/textAppearanceLarge"
+        android:singleLine="true"
+        android:ellipsize="marquee"
+        android:gravity="center_vertical"
+    />
+</merge>
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 69ab865..3ba7106 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -22,4 +22,5 @@
     <color name="quickcontact_disambig_divider">#afafaf</color>
 
     <color name="edit_divider">#ff666666</color>
+    <color name="background_secondary">#ff202020</color>
 </resources>
diff --git a/src/com/android/contacts/GroupingListAdapter.java b/src/com/android/contacts/GroupingListAdapter.java
new file mode 100644
index 0000000..885007f
--- /dev/null
+++ b/src/com/android/contacts/GroupingListAdapter.java
@@ -0,0 +1,464 @@
+/*
+ * Copyright (C) 2010 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.contacts;
+
+import com.android.internal.util.ArrayUtils;
+
+import android.content.Context;
+import android.database.CharArrayBuffer;
+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;
+
+/**
+ * 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.
+ */
+public 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;
+        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();
+        }
+
+        resetCache();
+        findGroups();
+    }
+
+    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 = ArrayUtils.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;
+    }
+
+    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.
+     */
+    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) {
+        obtainPositionMetadata(mPositionMetadata, position);
+        mCursor.moveToPosition(mPositionMetadata.cursorPosition);
+        return mCursor;
+    }
+
+    public long getItemId(int position) {
+        getItem(position);
+        return mCursor.getLong(mRowIdColumnIndex);
+    }
+
+    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/src/com/android/contacts/RecentCallsListActivity.java b/src/com/android/contacts/RecentCallsListActivity.java
index 547cd45..8e553d1 100644
--- a/src/com/android/contacts/RecentCallsListActivity.java
+++ b/src/com/android/contacts/RecentCallsListActivity.java
@@ -16,6 +16,9 @@
 
 package com.android.contacts;
 
+import com.android.internal.telephony.CallerInfo;
+import com.android.internal.telephony.ITelephony;
+
 import android.app.ListActivity;
 import android.content.ActivityNotFoundException;
 import android.content.AsyncQueryHandler;
@@ -23,9 +26,11 @@
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
+import android.database.CharArrayBuffer;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteDatabaseCorruptException;
 import android.database.sqlite.SQLiteDiskIOException;
+import android.database.sqlite.SQLiteException;
 import android.database.sqlite.SQLiteFullException;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
@@ -38,10 +43,10 @@
 import android.os.SystemClock;
 import android.provider.CallLog;
 import android.provider.CallLog.Calls;
+import android.provider.ContactsContract.Intents.Insert;
 import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.PhoneLookup;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
-import android.provider.Contacts.Intents.Insert;
 import android.telephony.PhoneNumberUtils;
 import android.telephony.TelephonyManager;
 import android.text.SpannableStringBuilder;
@@ -50,6 +55,7 @@
 import android.util.Log;
 import android.view.ContextMenu;
 import android.view.KeyEvent;
+import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
@@ -60,16 +66,12 @@
 import android.widget.AdapterView;
 import android.widget.ImageView;
 import android.widget.ListView;
-import android.widget.ResourceCursorAdapter;
 import android.widget.TextView;
 
-import com.android.internal.telephony.CallerInfo;
-import com.android.internal.telephony.ITelephony;
-
+import java.lang.ref.WeakReference;
 import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.Locale;
-import java.lang.ref.WeakReference;
 
 /**
  * Displays a list of call log entries.
@@ -143,6 +145,8 @@
         TextView dateView;
         ImageView iconView;
         View callView;
+        ImageView groupIndicator;
+        TextView groupSize;
     }
 
     static final class CallerInfoQuery {
@@ -171,7 +175,7 @@
     private static int sFormattingType = FORMATTING_TYPE_INVALID;
 
     /** Adapter class to fill in data for the Call Log */
-    final class RecentCallsAdapter extends ResourceCursorAdapter
+    final class RecentCallsAdapter extends GroupingListAdapter
             implements Runnable, ViewTreeObserver.OnPreDrawListener, View.OnClickListener {
         HashMap<String,ContactInfo> mContactInfo;
         private final LinkedList<CallerInfoQuery> mRequests;
@@ -189,6 +193,12 @@
         private Drawable mDrawableOutgoing;
         private Drawable mDrawableMissed;
 
+        /**
+         * Reusable char array buffers.
+         */
+        private CharArrayBuffer mBuffer1 = new CharArrayBuffer(128);
+        private CharArrayBuffer mBuffer2 = new CharArrayBuffer(128);
+
         public void onClick(View view) {
             String number = (String) view.getTag();
             if (!TextUtils.isEmpty(number)) {
@@ -220,7 +230,7 @@
         };
 
         public RecentCallsAdapter() {
-            super(RecentCallsListActivity.this, R.layout.recent_calls_list_item, null);
+            super(RecentCallsListActivity.this);
 
             mContactInfo = new HashMap<String,ContactInfo>();
             mRequests = new LinkedList<CallerInfoQuery>();
@@ -388,8 +398,116 @@
         }
 
         @Override
-        public View newView(Context context, Cursor cursor, ViewGroup parent) {
-            View view = super.newView(context, cursor, parent);
+        protected void addGroups(Cursor cursor) {
+
+            int count = cursor.getCount();
+            if (count == 0) {
+                return;
+            }
+
+            int groupItemCount = 1;
+
+            CharArrayBuffer currentValue = mBuffer1;
+            CharArrayBuffer value = mBuffer2;
+            cursor.moveToFirst();
+            cursor.copyStringToBuffer(NUMBER_COLUMN_INDEX, currentValue);
+            int currentCallType = cursor.getInt(CALL_TYPE_COLUMN_INDEX);
+            for (int i = 1; i < count; i++) {
+                cursor.moveToNext();
+                cursor.copyStringToBuffer(NUMBER_COLUMN_INDEX, value);
+                boolean sameNumber = equalPhoneNumbers(value, currentValue);
+
+                // Group adjacent calls with the same number. Make an exception
+                // for the latest item if it was a missed call.  We don't want
+                // a missed call to be hidden inside a group.
+                if (sameNumber && currentCallType != Calls.MISSED_TYPE) {
+                    groupItemCount++;
+                } else {
+                    if (groupItemCount > 1) {
+                        addGroup(i - groupItemCount, groupItemCount, false);
+                    }
+
+                    groupItemCount = 1;
+
+                    // Swap buffers
+                    CharArrayBuffer temp = currentValue;
+                    currentValue = value;
+                    value = temp;
+
+                    // If we have just examined a row following a missed call, make
+                    // sure that it is grouped with subsequent calls from the same number
+                    // even if it was also missed.
+                    if (sameNumber && currentCallType == Calls.MISSED_TYPE) {
+                        currentCallType = 0;       // "not a missed call"
+                    } else {
+                        currentCallType = cursor.getInt(CALL_TYPE_COLUMN_INDEX);
+                    }
+                }
+            }
+            if (groupItemCount > 1) {
+                addGroup(count - groupItemCount, groupItemCount, false);
+            }
+        }
+
+        protected boolean equalPhoneNumbers(CharArrayBuffer buffer1, CharArrayBuffer buffer2) {
+
+            // TODO add PhoneNumberUtils.compare(CharSequence, CharSequence) to avoid
+            // string allocation
+            return PhoneNumberUtils.compare(new String(buffer1.data, 0, buffer1.sizeCopied),
+                    new String(buffer2.data, 0, buffer2.sizeCopied));
+        }
+
+
+        @Override
+        protected View newStandAloneView(Context context, ViewGroup parent) {
+            LayoutInflater inflater =
+                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            View view = inflater.inflate(R.layout.recent_calls_list_item, parent, false);
+            findAndCacheViews(view);
+            return view;
+        }
+
+        @Override
+        protected void bindStandAloneView(View view, Context context, Cursor cursor) {
+            bindView(context, view, cursor);
+        }
+
+        @Override
+        protected View newChildView(Context context, ViewGroup parent) {
+            LayoutInflater inflater =
+                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            View view = inflater.inflate(R.layout.recent_calls_list_child_item, parent, false);
+            findAndCacheViews(view);
+            return view;
+        }
+
+        @Override
+        protected void bindChildView(View view, Context context, Cursor cursor) {
+            bindView(context, view, cursor);
+        }
+
+        @Override
+        protected View newGroupView(Context context, ViewGroup parent) {
+            LayoutInflater inflater =
+                    (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            View view = inflater.inflate(R.layout.recent_calls_list_group_item, parent, false);
+            findAndCacheViews(view);
+            return view;
+        }
+
+        @Override
+        protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize,
+                boolean expanded) {
+            final RecentCallsListItemViews views = (RecentCallsListItemViews) view.getTag();
+            int groupIndicator = expanded
+                    ? com.android.internal.R.drawable.expander_ic_maximized
+                    : com.android.internal.R.drawable.expander_ic_minimized;
+            views.groupIndicator.setImageResource(groupIndicator);
+            views.groupSize.setText("(" + groupSize + ")");
+            bindView(context, view, cursor);
+        }
+
+        private void findAndCacheViews(View view) {
 
             // Get the views to bind to
             RecentCallsListItemViews views = new RecentCallsListItemViews();
@@ -400,15 +518,12 @@
             views.iconView = (ImageView) view.findViewById(R.id.call_type_icon);
             views.callView = view.findViewById(R.id.call_icon);
             views.callView.setOnClickListener(this);
-
+            views.groupIndicator = (ImageView) view.findViewById(R.id.groupIndicator);
+            views.groupSize = (TextView) view.findViewById(R.id.groupSize);
             view.setTag(views);
-
-            return view;
         }
 
-
-        @Override
-        public void bindView(View view, Context context, Cursor c) {
+        public void bindView(Context context, View view, Cursor c) {
             final RecentCallsListItemViews views = (RecentCallsListItemViews) view.getTag();
 
             String number = c.getString(NUMBER_COLUMN_INDEX);
@@ -501,7 +616,6 @@
                 views.labelView.setVisibility(View.GONE);
             }
 
-            int type = c.getInt(CALL_TYPE_COLUMN_INDEX);
             long date = c.getLong(DATE_COLUMN_INDEX);
 
             // Set the date/time field by mixing relative and absolute times.
@@ -510,19 +624,22 @@
             views.dateView.setText(DateUtils.getRelativeTimeSpanString(date,
                     System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, flags));
 
-            // Set the icon
-            switch (type) {
-                case Calls.INCOMING_TYPE:
-                    views.iconView.setImageDrawable(mDrawableIncoming);
-                    break;
+            if (views.iconView != null) {
+                int type = c.getInt(CALL_TYPE_COLUMN_INDEX);
+                // Set the icon
+                switch (type) {
+                    case Calls.INCOMING_TYPE:
+                        views.iconView.setImageDrawable(mDrawableIncoming);
+                        break;
 
-                case Calls.OUTGOING_TYPE:
-                    views.iconView.setImageDrawable(mDrawableOutgoing);
-                    break;
+                    case Calls.OUTGOING_TYPE:
+                        views.iconView.setImageDrawable(mDrawableOutgoing);
+                        break;
 
-                case Calls.MISSED_TYPE:
-                    views.iconView.setImageDrawable(mDrawableMissed);
-                    break;
+                    case Calls.MISSED_TYPE:
+                        views.iconView.setImageDrawable(mDrawableMissed);
+                        break;
+                }
             }
 
             // Listen for the first draw
@@ -819,12 +936,24 @@
 
         switch (item.getItemId()) {
             case MENU_ITEM_DELETE: {
-                Cursor cursor = mAdapter.getCursor();
-                if (cursor != null) {
-                    cursor.moveToPosition(menuInfo.position);
-                    cursor.deleteRow();
+                Cursor cursor = (Cursor)mAdapter.getItem(menuInfo.position);
+                int groupSize = 1;
+                if (mAdapter.isGroupHeader(menuInfo.position)) {
+                    groupSize = mAdapter.getGroupSize(menuInfo.position);
                 }
-                return true;
+
+                StringBuilder sb = new StringBuilder();
+                for (int i = 0; i < groupSize; i++) {
+                    if (i != 0) {
+                        sb.append(",");
+                        cursor.moveToNext();
+                    }
+                    long id = cursor.getLong(ID_COLUMN_INDEX);
+                    sb.append(id);
+                }
+
+                getContentResolver().delete(Calls.CONTENT_URI, Calls._ID + " IN (" + sb + ")",
+                        null);
             }
         }
         return super.onContextItemSelected(item);
@@ -946,8 +1075,12 @@
 
     @Override
     protected void onListItemClick(ListView l, View v, int position, long id) {
-        Intent intent = new Intent(this, CallDetailActivity.class);
-        intent.setData(ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI, id));
-        startActivity(intent);
+        if (mAdapter.isGroupHeader(position)) {
+            mAdapter.toggleGroup(position);
+        } else {
+            Intent intent = new Intent(this, CallDetailActivity.class);
+            intent.setData(ContentUris.withAppendedId(CallLog.Calls.CONTENT_URI, id));
+            startActivity(intent);
+        }
     }
 }
diff --git a/tests/src/com/android/contacts/GroupingListAdapterTests.java b/tests/src/com/android/contacts/GroupingListAdapterTests.java
new file mode 100644
index 0000000..1877fac
--- /dev/null
+++ b/tests/src/com/android/contacts/GroupingListAdapterTests.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2010 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.contacts;
+
+import android.content.Context;
+import android.database.CharArrayBuffer;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.provider.CallLog.Calls;
+import android.test.AndroidTestCase;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewGroup;
+
+import static com.android.contacts.GroupingListAdapter.ITEM_TYPE_STANDALONE;
+import static com.android.contacts.GroupingListAdapter.ITEM_TYPE_IN_GROUP;
+import static com.android.contacts.GroupingListAdapter.ITEM_TYPE_GROUP_HEADER;
+
+/**
+ * Tests for the contact call list adapter.
+ *
+ * Running all tests:
+ *
+ *   runtest contacts
+ * or
+ *   adb shell am instrument \
+ *     -w com.android.contacts.tests/android.test.InstrumentationTestRunner
+ */
+public class GroupingListAdapterTests extends AndroidTestCase {
+
+    static private final String[] CALL_LOG_PROJECTION = new String[] {
+        Calls._ID,
+        Calls.NUMBER,
+        Calls.DATE,
+    };
+
+    private static final int CALLS_NUMBER_COLUMN_INDEX = 1;
+
+    private MatrixCursor mCursor;
+    private long mNextCall;
+
+    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(CALLS_NUMBER_COLUMN_INDEX);
+            for (int i = 1; i < count; i++) {
+                cursor.moveToNext();
+                String value = cursor.getString(CALLS_NUMBER_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(CALL_LOG_PROJECTION);
+        mNextCall = 1;
+        for (String number : numbers) {
+            mCursor.addRow(new Object[]{mNextCall, number, 1000 - mNextCall});
+            mNextCall++;
+        }
+    }
+
+    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);
+    }
+}
diff --git a/tests/src/com/android/contacts/RecentCallsListActivityTests.java b/tests/src/com/android/contacts/RecentCallsListActivityTests.java
index afa1a7a..d00854f 100644
--- a/tests/src/com/android/contacts/RecentCallsListActivityTests.java
+++ b/tests/src/com/android/contacts/RecentCallsListActivityTests.java
@@ -97,7 +97,7 @@
 
     @Override
     public void setUp() {
-        mActivity = (RecentCallsListActivity) getActivity();
+        mActivity = getActivity();
         mVoicemail = mActivity.mVoiceMailNumber;
         mAdapter = mActivity.mAdapter;
         mParentView = new FrameLayout(mActivity);
@@ -224,9 +224,9 @@
         mCursor.moveToLast();
         while(!mCursor.isBeforeFirst()) {
             if (null == mList[i]) {
-                mList[i] = mAdapter.newView(mActivity, mCursor, mParentView);
+                mList[i] = mAdapter.newStandAloneView(mActivity, mParentView);
             }
-            mAdapter.bindView(mList[i], mActivity, mCursor);
+            mAdapter.bindStandAloneView(mList[i], mActivity, mCursor);
             mCursor.moveToPrevious();
             i++;
         }