Wrote groups support, minor refactoring work.

Added groups into the provider, including a summary Uri that
feeds back high-level details about the number of people
inside each group.  Wrote medium test suite to make sure
summary values are counted correctly.

Added a column so that the visibility of groups can be
changed for showing in UI.  This also introduces a new
"visible" flag on each aggregate to keep building the list-
of-contacts cursor spiffy.  (Any update to the group
visibility flag triggers an automatic rebuild of the
aggregate visibility flags using a single update pass.)

Expanded data deletion code to handle best-effort promotion
of a new super-primary when one is deleted.  We first try
finding another data item with an identical value to promote
in its place, otherwise fall back to picking an arbitrary
primary from constituent contacts.

Minor refactoring of "MIMETYPE" to "MIMETYPES" and "PACKAGE"
to "PACKAGES" to keep in line with all other table names
being plural.

Refactored the ContactsActor class in the test suites so
that methods are called directly on the actor.  Allows for
better borrowing of code between test cases.
diff --git a/src/com/android/providers/contacts2/ContactAggregator.java b/src/com/android/providers/contacts2/ContactAggregator.java
index e261fb8..18a765b 100644
--- a/src/com/android/providers/contacts2/ContactAggregator.java
+++ b/src/com/android/providers/contacts2/ContactAggregator.java
@@ -22,7 +22,7 @@
 import com.android.providers.contacts2.OpenHelper.Clauses;
 import com.android.providers.contacts2.OpenHelper.ContactOptionsColumns;
 import com.android.providers.contacts2.OpenHelper.ContactsColumns;
-import com.android.providers.contacts2.OpenHelper.MimetypeColumns;
+import com.android.providers.contacts2.OpenHelper.MimetypesColumns;
 import com.android.providers.contacts2.OpenHelper.NameLookupColumns;
 import com.android.providers.contacts2.OpenHelper.NameLookupType;
 import com.android.providers.contacts2.OpenHelper.Tables;
@@ -33,6 +33,7 @@
 import android.database.DatabaseUtils;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
 import android.provider.ContactsContract.Aggregates;
 import android.provider.ContactsContract.AggregationExceptions;
 import android.provider.ContactsContract.CommonDataKinds;
@@ -66,14 +67,14 @@
     private static final String TAG = "ContactAggregator";
 
     // Data mime types used in the contact matching algorithm
-    private static final String MIMETYPE_SELECTION_IN_CLAUSE = MimetypeColumns.MIMETYPE + " IN ('"
+    private static final String MIMETYPE_SELECTION_IN_CLAUSE = MimetypesColumns.MIMETYPE + " IN ('"
             + Email.CONTENT_ITEM_TYPE + "','"
             + Nickname.CONTENT_ITEM_TYPE + "','"
             + Phone.CONTENT_ITEM_TYPE + "','"
             + StructuredName.CONTENT_ITEM_TYPE + "')";
 
     private static final String[] DATA_JOIN_MIMETYPE_COLUMNS = new String[] {
-            MimetypeColumns.MIMETYPE,
+            MimetypesColumns.MIMETYPE,
             Data.DATA1,
             Data.DATA2
     };
@@ -132,6 +133,9 @@
     // Set if the current aggregation pass should be interrupted
     private volatile boolean mCancel;
 
+    /** Compiled statement for updating {@link Aggregates#IN_VISIBLE_GROUP}. */
+    private SQLiteStatement mUpdateAggregateVisibleStatement;
+
     /**
      * Captures a potential match for a given name. The matching algorithm
      * constructs a bunch of NameMatchCandidate objects for various potential matches
@@ -340,6 +344,8 @@
 
         updateAggregateData(db, aggregateId, values);
         updatePrimaries(db, aggregateId, contactId, newAgg);
+        mOpenHelper.updateAggregateVisible(aggregateId);
+
     }
 
     /**
@@ -434,7 +440,7 @@
             selection.append(secondaryAggregateIds.get(i));
         }
         selection.append(") AND ")
-                .append(MimetypeColumns.MIMETYPE)
+                .append(MimetypesColumns.MIMETYPE)
                 .append("='")
                 .append(StructuredName.CONTENT_ITEM_TYPE)
                 .append("'");
@@ -485,7 +491,7 @@
     private void updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long contactId,
             int mode, MatchCandidateList candidates, ContactMatcher matcher) {
 
-        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE,
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS,
                 DATA_JOIN_MIMETYPE_COLUMNS,
                 DatabaseUtils.concatenateWhere(Data.CONTACT_ID + "=" + contactId,
                         MIMETYPE_SELECTION_IN_CLAUSE),
@@ -772,7 +778,7 @@
             MatchCandidateList candidates, ContentValues values) {
         candidates.clear();
 
-        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE,
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPES,
                 DATA_JOIN_MIMETYPE_COLUMNS,
                 DatabaseUtils.concatenateWhere(Data.CONTACT_ID + "=" + contactId,
                         MIMETYPE_SELECTION_IN_CLAUSE),
@@ -867,7 +873,7 @@
         // Find primary data items from newly-joined contact, returning one
         // candidate for each mimetype.
         try {
-            cursor = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE, Projections.PROJ_DATA,
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES, Projections.PROJ_DATA,
                     Data.CONTACT_ID + "=" + contactId + " AND " + Data.IS_PRIMARY + "=1 AND "
                             + Projections.PRIMARY_MIME_CLAUSE, null, Data.MIMETYPE, null, null);
             while (cursor.moveToNext()) {
@@ -944,7 +950,7 @@
     private String getBestDisplayName(SQLiteDatabase db, long aggregateId) {
         String bestDisplayName = null;
 
-        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES,
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES,
                 new String[] {StructuredName.DISPLAY_NAME},
                 DatabaseUtils.concatenateWhere(Contacts.AGGREGATE_ID + "=" + aggregateId,
                         Data.MIMETYPE + "='" + StructuredName.CONTENT_ITEM_TYPE + "'"),
diff --git a/src/com/android/providers/contacts2/ContactsProvider2.java b/src/com/android/providers/contacts2/ContactsProvider2.java
index 89744d1..94c997a 100644
--- a/src/com/android/providers/contacts2/ContactsProvider2.java
+++ b/src/com/android/providers/contacts2/ContactsProvider2.java
@@ -22,6 +22,8 @@
 import com.android.providers.contacts2.OpenHelper.ContactsColumns;
 import com.android.providers.contacts2.OpenHelper.ContactOptionsColumns;
 import com.android.providers.contacts2.OpenHelper.DataColumns;
+import com.android.providers.contacts2.OpenHelper.GroupsColumns;
+import com.android.providers.contacts2.OpenHelper.MimetypesColumns;
 import com.android.providers.contacts2.OpenHelper.PhoneLookupColumns;
 import com.android.providers.contacts2.OpenHelper.Tables;
 
@@ -40,6 +42,7 @@
 import android.content.UriMatcher;
 import android.content.pm.PackageManager;
 import android.database.Cursor;
+import android.database.DatabaseUtils;
 import android.database.sqlite.SQLiteCursor;
 import android.database.sqlite.SQLiteDatabase;
 import android.database.sqlite.SQLiteQueryBuilder;
@@ -57,9 +60,12 @@
 import android.provider.ContactsContract.CommonDataKinds;
 import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.Presence;
 import android.provider.ContactsContract.RestrictionExceptions;
 import android.provider.ContactsContract.Aggregates.AggregationSuggestions;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 import android.provider.ContactsContract.CommonDataKinds.Postal;
 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
@@ -128,25 +134,53 @@
 
     private static final int RESTRICTION_EXCEPTIONS = 9000;
 
-    private static final String[] CONTACT_PROJECTION = new String[] {
-            Contacts._ID
-    };
+    private static final int GROUPS = 10000;
+    private static final int GROUPS_ID = 10001;
+    private static final int GROUPS_SUMMARY = 10003;
 
-    private static final int CONTACT_COLUMN_CONTACT_ID = 0;
+    private interface Projections {
+        public static final String[] PROJ_CONTACTS = new String[] {
+            ContactsColumns.CONCRETE_ID,
+        };
 
-    private static final String[] PROJ_DATA_CONTACTS = new String[] {
-            Tables.DATA + "." + Data._ID,
-            Contacts.AGGREGATE_ID,
-            ContactsColumns.PACKAGE_ID,
-            Contacts.IS_RESTRICTED,
-            Data.MIMETYPE,
-    };
+        public static final String[] PROJ_DATA_CONTACTS = new String[] {
+                ContactsColumns.CONCRETE_ID,
+                DataColumns.CONCRETE_ID,
+                Contacts.AGGREGATE_ID,
+                ContactsColumns.PACKAGE_ID,
+                Contacts.IS_RESTRICTED,
+                Data.MIMETYPE,
+        };
 
-    private static final int COL_DATA_ID = 0;
-    private static final int COL_AGGREGATE_ID = 1;
-    private static final int COL_PACKAGE_ID = 2;
-    private static final int COL_IS_RESTRICTED = 3;
-    private static final int COL_MIMETYPE = 4;
+        public static final int COL_CONTACT_ID = 0;
+        public static final int COL_DATA_ID = 1;
+        public static final int COL_AGGREGATE_ID = 2;
+        public static final int COL_PACKAGE_ID = 3;
+        public static final int COL_IS_RESTRICTED = 4;
+        public static final int COL_MIMETYPE = 5;
+
+        public static final String[] PROJ_DATA_AGGREGATES = new String[] {
+            ContactsColumns.CONCRETE_ID,
+                DataColumns.CONCRETE_ID,
+                AggregatesColumns.CONCRETE_ID,
+                MimetypesColumns.CONCRETE_ID,
+                Phone.NUMBER,
+                Email.DATA,
+                AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID,
+                AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID,
+        };
+
+        public static final int COL_MIMETYPE_ID = 3;
+        public static final int COL_PHONE_NUMBER = 4;
+        public static final int COL_EMAIL_DATA = 5;
+        public static final int COL_OPTIMAL_PHONE_ID = 6;
+        public static final int COL_FALLBACK_PHONE_ID = 7;
+        public static final int COL_OPTIMAL_EMAIL_ID = 8;
+        public static final int COL_FALLBACK_EMAIL_ID = 9;
+
+    }
 
     /** Default for the maximum number of returned aggregation suggestions. */
     private static final int DEFAULT_MAX_SUGGESTIONS = 5;
@@ -167,6 +201,10 @@
     private static final HashMap<String, String> sDataContactsAccountsProjectionMap;
     /** Contains just the key and value columns */
     private static final HashMap<String, String> sAccountsProjectionMap;
+    /** Contains the just the {@link Groups} columns */
+    private static final HashMap<String, String> sGroupsProjectionMap;
+    /** Contains {@link Groups} columns along with summary details */
+    private static final HashMap<String, String> sGroupsSummaryProjectionMap;
     /** Contains the just the agg_exceptions columns */
     private static final HashMap<String, String> sAggregationExceptionsProjectionMap;
     /** Contains the just the {@link RestrictionExceptions} columns */
@@ -227,6 +265,10 @@
         matcher.addURI(ContactsContract.AUTHORITY, "data/phones/filter/*", PHONES_FILTER);
         matcher.addURI(ContactsContract.AUTHORITY, "data/postals", POSTALS);
 
+        matcher.addURI(ContactsContract.AUTHORITY, "groups", GROUPS);
+        matcher.addURI(ContactsContract.AUTHORITY, "groups/#", GROUPS_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "groups_summary", GROUPS_SUMMARY);
+
         matcher.addURI(ContactsContract.AUTHORITY, "phone_lookup/*", PHONE_LOOKUP);
         matcher.addURI(ContactsContract.AUTHORITY, "aggregation_exceptions",
                 AGGREGATION_EXCEPTIONS);
@@ -259,6 +301,7 @@
         columns.put(Aggregates.LAST_TIME_CONTACTED, Aggregates.LAST_TIME_CONTACTED);
         columns.put(Aggregates.TIMES_CONTACTED, Aggregates.TIMES_CONTACTED);
         columns.put(Aggregates.STARRED, Aggregates.STARRED);
+        columns.put(Aggregates.IN_VISIBLE_GROUP, Aggregates.IN_VISIBLE_GROUP);
         columns.put(Aggregates.PRIMARY_PHONE_ID, Aggregates.PRIMARY_PHONE_ID);
         columns.put(Aggregates.PRIMARY_EMAIL_ID, Aggregates.PRIMARY_EMAIL_ID);
         columns.put(Aggregates.CUSTOM_RINGTONE, Aggregates.CUSTOM_RINGTONE);
@@ -317,7 +360,7 @@
         columns = new HashMap<String, String>();
         columns.putAll(sContactsProjectionMap);
         columns.putAll(sDataProjectionMap); // _id will be replaced with the one from data
-        columns.put(Data.CONTACT_ID, "data.contact_id");
+        columns.put(Data.CONTACT_ID, DataColumns.CONCRETE_CONTACT_ID);
         sDataContactsProjectionMap = columns;
 
         columns = new HashMap<String, String>();
@@ -331,9 +374,36 @@
         columns.putAll(sAggregatesProjectionMap);
         columns.putAll(sContactsProjectionMap); //
         columns.putAll(sDataProjectionMap); // _id will be replaced with the one from data
-        columns.put(Data.CONTACT_ID, "data.contact_id");
+        columns.put(Data.CONTACT_ID, DataColumns.CONCRETE_CONTACT_ID);
         sDataContactsAggregateProjectionMap = columns;
 
+        // Groups projection map
+        columns = new HashMap<String, String>();
+        columns.put(Groups._ID, "groups._id AS _id");
+        columns.put(Groups.PACKAGE, Groups.PACKAGE);
+        columns.put(Groups.PACKAGE_ID, GroupsColumns.CONCRETE_PACKAGE_ID);
+        columns.put(Groups.TITLE, Groups.TITLE);
+        columns.put(Groups.TITLE_RESOURCE, Groups.TITLE_RESOURCE);
+        columns.put(Groups.GROUP_VISIBLE, Groups.GROUP_VISIBLE);
+        sGroupsProjectionMap = columns;
+
+        // Contacts and groups projection map
+        columns = new HashMap<String, String>();
+        columns.putAll(sGroupsProjectionMap);
+
+        columns.put(Groups.SUMMARY_COUNT, "(SELECT COUNT(DISTINCT " + AggregatesColumns.CONCRETE_ID
+                + ") FROM " + Tables.DATA_JOIN_MIMETYPES_CONTACTS_AGGREGATES + " WHERE "
+                + Clauses.MIMETYPE_IS_GROUP_MEMBERSHIP + " AND " + Clauses.BELONGS_TO_GROUP
+                + ") AS " + Groups.SUMMARY_COUNT);
+
+        columns.put(Groups.SUMMARY_WITH_PHONES, "(SELECT COUNT(DISTINCT "
+                + AggregatesColumns.CONCRETE_ID + ") FROM "
+                + Tables.DATA_JOIN_MIMETYPES_CONTACTS_AGGREGATES + " WHERE "
+                + Clauses.MIMETYPE_IS_GROUP_MEMBERSHIP + " AND " + Clauses.BELONGS_TO_GROUP
+                + " AND " + Clauses.HAS_PRIMARY_PHONE + ") AS " + Groups.SUMMARY_WITH_PHONES);
+
+        sGroupsSummaryProjectionMap = columns;
+
         // Aggregate exception projection map
         columns = new HashMap<String, String>();
         columns.put(AggregationExceptionColumns._ID, Tables.AGGREGATION_EXCEPTIONS + "._id AS _id");
@@ -586,6 +656,12 @@
                 break;
             }
 
+            case GROUPS: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertGroup(values, account);
+                break;
+            }
+
             case PRESENCE: {
                 id = insertPresence(values);
                 break;
@@ -750,6 +826,153 @@
     }
 
     /**
+     * Delete the given {@link Data} row, fixing up any {@link Aggregates}
+     * primaries that reference it.
+     */
+    private int deleteData(long dataId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        final long mimePhone = mOpenHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE);
+        final long mimeEmail = mOpenHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE);
+
+        // Check to see if the data about to be deleted was a super-primary on
+        // the parent aggregate, and set flags to fix-up once deleted.
+        long aggId = -1;
+        long mimeId = -1;
+        String dataRaw = null;
+        boolean fixOptimal = false;
+        boolean fixFallback = false;
+
+        Cursor cursor = null;
+        try {
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_AGGREGATES,
+                    Projections.PROJ_DATA_AGGREGATES, DataColumns.CONCRETE_ID + "=" + dataId, null,
+                    null, null, null);
+            if (cursor.moveToFirst()) {
+                aggId = cursor.getLong(Projections.COL_AGGREGATE_ID);
+                mimeId = cursor.getLong(Projections.COL_MIMETYPE_ID);
+                if (mimeId == mimePhone) {
+                    dataRaw = cursor.getString(Projections.COL_PHONE_NUMBER);
+                    fixOptimal = (cursor.getLong(Projections.COL_OPTIMAL_PHONE_ID) == dataId);
+                    fixFallback = (cursor.getLong(Projections.COL_FALLBACK_PHONE_ID) == dataId);
+                } else if (mimeId == mimeEmail) {
+                    dataRaw = cursor.getString(Projections.COL_EMAIL_DATA);
+                    fixOptimal = (cursor.getLong(Projections.COL_OPTIMAL_EMAIL_ID) == dataId);
+                    fixFallback = (cursor.getLong(Projections.COL_FALLBACK_EMAIL_ID) == dataId);
+                }
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+                cursor = null;
+            }
+        }
+
+        // Delete the requested data item.
+        int dataDeleted = db.delete(Tables.DATA, Data._ID + "=" + dataId, null);
+
+        // Fix-up any super-primary values that are now invalid.
+        if (fixOptimal || fixFallback) {
+            final ContentValues values = new ContentValues();
+            final StringBuilder scoreClause = new StringBuilder();
+
+            final String SCORE = "score";
+
+            // Build scoring clause that will first pick data items under the
+            // same aggregate that have identical values, otherwise fall back to
+            // normal primary scoring from the member contacts.
+            scoreClause.append("(CASE WHEN ");
+            if (mimeId == mimePhone) {
+                scoreClause.append(Phone.NUMBER);
+            } else if (mimeId == mimeEmail) {
+                scoreClause.append(Email.DATA);
+            }
+            scoreClause.append("=");
+            DatabaseUtils.appendEscapedSQLString(scoreClause, dataRaw);
+            scoreClause.append(" THEN 2 ELSE " + Data.IS_PRIMARY + " END) AS " + SCORE);
+
+            final String[] PROJ_PRIMARY = new String[] {
+                    DataColumns.CONCRETE_ID,
+                    Contacts.IS_RESTRICTED,
+                    ContactsColumns.PACKAGE_ID,
+                    scoreClause.toString(),
+            };
+
+            final int COL_DATA_ID = 0;
+            final int COL_IS_RESTRICTED = 1;
+            final int COL_PACKAGE_ID = 2;
+            final int COL_SCORE = 3;
+
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_AGGREGATES, PROJ_PRIMARY,
+                    AggregatesColumns.CONCRETE_ID + "=" + aggId + " AND " + DataColumns.MIMETYPE_ID
+                            + "=" + mimeId, null, null, null, SCORE);
+
+            if (fixOptimal) {
+                String colId = null;
+                String colPackageId = null;
+                if (mimeId == mimePhone) {
+                    colId = AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID;
+                    colPackageId = AggregatesColumns.OPTIMAL_PRIMARY_PHONE_PACKAGE_ID;
+                } else if (mimeId == mimeEmail) {
+                    colId = AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID;
+                    colPackageId = AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID;
+                }
+
+                // Start by replacing with null, since fixOptimal told us that
+                // the previous aggregate values are bad.
+                values.putNull(colId);
+                values.putNull(colPackageId);
+
+                // When finding a new optimal primary, we only care about the
+                // highest scoring value, regardless of source.
+                if (cursor.moveToFirst()) {
+                    final long newOptimal = cursor.getLong(COL_DATA_ID);
+                    final long newOptimalPackage = cursor.getLong(COL_PACKAGE_ID);
+
+                    if (newOptimal != 0) {
+                        values.put(colId, newOptimal);
+                    }
+                    if (newOptimalPackage != 0) {
+                        values.put(colPackageId, newOptimalPackage);
+                    }
+                }
+            }
+
+            if (fixFallback) {
+                String colId = null;
+                if (mimeId == mimePhone) {
+                    colId = AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID;
+                } else if (mimeId == mimeEmail) {
+                    colId = AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID;
+                }
+
+                // Start by replacing with null, since fixFallback told us that
+                // the previous aggregate values are bad.
+                values.putNull(colId);
+
+                // The best fallback value is the highest scoring data item that
+                // hasn't been restricted.
+                cursor.moveToPosition(-1);
+                while (cursor.moveToNext()) {
+                    final boolean isRestricted = (cursor.getInt(COL_IS_RESTRICTED) == 1);
+                    if (!isRestricted) {
+                        values.put(colId, cursor.getLong(COL_DATA_ID));
+                        break;
+                    }
+                }
+            }
+
+            // Push through any aggregate updates we have
+            if (values.size() > 0) {
+                db.update(Tables.AGGREGATES, values, AggregatesColumns.CONCRETE_ID + "=" + aggId,
+                        null);
+            }
+        }
+
+        return dataDeleted;
+    }
+
+    /**
      * Parse the supplied display name, but only if the incoming values do not already contain
      * structured name parts.
      */
@@ -775,6 +998,25 @@
     }
 
     /**
+     * Inserts an item in the groups table
+     */
+    private long insertGroup(ContentValues values, Account account) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        ContentValues overriddenValues = new ContentValues(values);
+        if (!resolveAccount(overriddenValues, account)) {
+            return -1;
+        }
+
+        // Replace package with internal mapping
+        final String packageName = overriddenValues.getAsString(Groups.PACKAGE);
+        overriddenValues.put(Groups.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+        overriddenValues.remove(Groups.PACKAGE);
+
+        return db.insert(Tables.GROUPS, Groups.TITLE, overriddenValues);
+    }
+
+    /**
      * Inserts a presence update.
      */
     private long insertPresence(ContentValues values) {
@@ -802,11 +1044,11 @@
         long aggId = -1;
         Cursor cursor = null;
         try {
-            cursor = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES,
-                    PROJ_DATA_CONTACTS, selection, selectionArgs, null, null, null);
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES,
+                    Projections.PROJ_DATA_CONTACTS, selection, selectionArgs, null, null, null);
             if (cursor.moveToFirst()) {
-                dataId = cursor.getLong(COL_DATA_ID);
-                aggId = cursor.getLong(COL_AGGREGATE_ID);
+                dataId = cursor.getLong(Projections.COL_DATA_ID);
+                aggId = cursor.getLong(Projections.COL_AGGREGATE_ID);
             } else {
                 // No contact found, return a null URI
                 return -1;
@@ -857,7 +1099,19 @@
 
             case DATA_ID: {
                 long dataId = ContentUris.parseId(uri);
-                return db.delete(Tables.DATA, Data._ID + "=" + dataId, null);
+                return deleteData(dataId);
+            }
+
+            case GROUPS_ID: {
+                long groupId = ContentUris.parseId(uri);
+                final long groupMembershipMimetypeId = mOpenHelper
+                        .getMimeTypeId(GroupMembership.CONTENT_ITEM_TYPE);
+                int groupsDeleted = db.delete(Tables.GROUPS, Groups._ID + "=" + groupId, null);
+                int dataDeleted = db.delete(Tables.DATA, DataColumns.MIMETYPE_ID + "="
+                        + groupMembershipMimetypeId + " AND " + GroupMembership.GROUP_ROW_ID + "="
+                        + groupId, null);
+                mOpenHelper.updateAllVisible();
+                return groupsDeleted + dataDeleted;
             }
 
             case PRESENCE: {
@@ -878,6 +1132,7 @@
         return new Account(name, type);
     }
 
+
     @Override
     public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
         int count = 0;
@@ -979,6 +1234,26 @@
                 break;
             }
 
+            case GROUPS: {
+                count = db.update(Tables.GROUPS, values, selection, selectionArgs);
+                mOpenHelper.updateAllVisible();
+                break;
+            }
+
+            case GROUPS_ID: {
+                long groupId = ContentUris.parseId(uri);
+                String selectionWithId = (Groups._ID + "=" + groupId + " ")
+                        + (selection == null ? "" : " AND " + selection);
+                count = db.update(Tables.GROUPS, values, selectionWithId, selectionArgs);
+
+                // If changing visibility, then update aggregates
+                if (values.containsKey(Groups.GROUP_VISIBLE)) {
+                    mOpenHelper.updateAllVisible();
+                }
+
+                break;
+            }
+
             case AGGREGATION_EXCEPTIONS: {
                 count = updateAggregationException(db, values);
                 break;
@@ -1050,11 +1325,11 @@
             return 0;
         }
 
-        Cursor c = db.query(Tables.CONTACTS, CONTACT_PROJECTION, Contacts.AGGREGATE_ID + "="
+        Cursor c = db.query(Tables.CONTACTS, Projections.PROJ_CONTACTS, Contacts.AGGREGATE_ID + "="
                 + aggregateId, null, null, null, null);
         try {
             while (c.moveToNext()) {
-                long contactId = c.getLong(CONTACT_COLUMN_CONTACT_ID);
+                long contactId = c.getLong(Projections.COL_CONTACT_ID);
 
                 optionValues.put(ContactOptionsColumns._ID, contactId);
                 db.replace(Tables.CONTACT_OPTIONS, null, optionValues);
@@ -1103,12 +1378,12 @@
 
         // First, we build a list of contactID-contactID pairs for the given aggregate and contact.
         ArrayList<ContactPair> pairs = new ArrayList<ContactPair>();
-        Cursor c = db.query(Tables.CONTACTS, CONTACT_PROJECTION,
+        Cursor c = db.query(Tables.CONTACTS, Projections.PROJ_CONTACTS,
                 Contacts.AGGREGATE_ID + "=" + aggregateId,
                 null, null, null, null);
         try {
             while (c.moveToNext()) {
-                long aggregatedContactId = c.getLong(CONTACT_COLUMN_CONTACT_ID);
+                long aggregatedContactId = c.getLong(Projections.COL_CONTACT_ID);
                 if (aggregatedContactId != contactId) {
                     pairs.add(new ContactPair(aggregatedContactId, contactId));
                 }
@@ -1214,7 +1489,7 @@
             case AGGREGATES_ID: {
                 long aggId = ContentUris.parseId(uri);
                 qb.setTables(Tables.AGGREGATES);
-                qb.appendWhere(Tables.AGGREGATES + "." + Aggregates._ID + "=" + aggId + " AND ");
+                qb.appendWhere(AggregatesColumns.CONCRETE_ID + "=" + aggId + " AND ");
                 applyAggregateRestrictionExceptions(qb);
                 applyAggregatePrimaryRestrictionExceptions(sAggregatesProjectionMap);
                 qb.setProjectionMap(sAggregatesProjectionMap);
@@ -1236,7 +1511,7 @@
                 // TODO: join into social status tables
                 long aggId = ContentUris.parseId(uri);
                 qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
-                qb.appendWhere(Tables.AGGREGATES + "." + Aggregates._ID + "=" + aggId + " AND ");
+                qb.appendWhere(AggregatesColumns.CONCRETE_ID + "=" + aggId + " AND ");
                 applyAggregateRestrictionExceptions(qb);
                 applyAggregatePrimaryRestrictionExceptions(sAggregatesSummaryProjectionMap);
                 projection = assertContained(projection, Aggregates.PRIMARY_PHONE_ID);
@@ -1297,7 +1572,7 @@
 
             case AGGREGATES_DATA: {
                 long aggId = Long.parseLong(uri.getPathSegments().get(1));
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sDataContactsAggregateProjectionMap);
                 qb.appendWhere(Contacts.AGGREGATE_ID + "=" + aggId + " AND ");
                 applyDataRestrictionExceptions(qb);
@@ -1305,7 +1580,7 @@
             }
 
             case PHONES_FILTER: {
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sDataContactsAggregateProjectionMap);
                 qb.appendWhere(Data.MIMETYPE + " = '" + Phone.CONTENT_ITEM_TYPE + "'");
                 if (uri.getPathSegments().size() > 2) {
@@ -1314,22 +1589,23 @@
                 }
                 break;
             }
+
             case PHONES: {
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sDataContactsAggregateProjectionMap);
                 qb.appendWhere(Data.MIMETYPE + " = \"" + Phone.CONTENT_ITEM_TYPE + "\"");
                 break;
             }
 
             case POSTALS: {
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sDataContactsAggregateProjectionMap);
                 qb.appendWhere(Data.MIMETYPE + " = \"" + Postal.CONTENT_ITEM_TYPE + "\"");
                 break;
             }
 
             case CONTACTS: {
-                qb.setTables(Tables.CONTACTS_JOIN_PACKAGE_ACCOUNTS);
+                qb.setTables(Tables.CONTACTS_JOIN_PACKAGES_ACCOUNTS);
                 qb.setProjectionMap(sContactsProjectionMap);
                 applyContactsRestrictionExceptions(qb);
                 break;
@@ -1337,16 +1613,16 @@
 
             case CONTACTS_ID: {
                 long contactId = ContentUris.parseId(uri);
-                qb.setTables(Tables.CONTACTS_JOIN_PACKAGE_ACCOUNTS);
+                qb.setTables(Tables.CONTACTS_JOIN_PACKAGES_ACCOUNTS);
                 qb.setProjectionMap(sContactsProjectionMap);
-                qb.appendWhere(Tables.CONTACTS + "." + BaseColumns._ID + "=" + contactId + " AND ");
+                qb.appendWhere(ContactsColumns.CONCRETE_ID + "=" + contactId + " AND ");
                 applyContactsRestrictionExceptions(qb);
                 break;
             }
 
             case CONTACTS_DATA: {
                 long contactId = Long.parseLong(uri.getPathSegments().get(1));
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
                 qb.setProjectionMap(sDataContactsProjectionMap);
                 qb.appendWhere(Data.CONTACT_ID + "=" + contactId + " AND ");
                 applyDataRestrictionExceptions(qb);
@@ -1355,7 +1631,7 @@
 
             case CONTACTS_FILTER_EMAIL: {
                 // TODO: filter query based on callingUid
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sDataContactsProjectionMap);
                 qb.appendWhere(Data.MIMETYPE + "='" + CommonDataKinds.Email.CONTENT_ITEM_TYPE + "'");
                 qb.appendWhere(" AND " + CommonDataKinds.Email.DATA + "=");
@@ -1375,16 +1651,16 @@
                     }
                     qb.appendWhere(Contacts.ACCOUNTS_ID + "=" + accountId + " AND ");
                 }
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
                 qb.setProjectionMap(sDataProjectionMap);
                 applyDataRestrictionExceptions(qb);
                 break;
             }
 
             case DATA_ID: {
-                qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE);
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
                 qb.setProjectionMap(sDataProjectionMap);
-                qb.appendWhere("data._id = " + ContentUris.parseId(uri) + " AND ");
+                qb.appendWhere(DataColumns.CONCRETE_ID + "=" + ContentUris.parseId(uri) + " AND ");
                 applyDataRestrictionExceptions(qb);
                 break;
             }
@@ -1403,6 +1679,27 @@
                 break;
             }
 
+            case GROUPS: {
+                qb.setTables(Tables.GROUPS_JOIN_PACKAGES);
+                qb.setProjectionMap(sGroupsProjectionMap);
+                break;
+            }
+
+            case GROUPS_ID: {
+                long groupId = ContentUris.parseId(uri);
+                qb.setTables(Tables.GROUPS_JOIN_PACKAGES);
+                qb.setProjectionMap(sGroupsProjectionMap);
+                qb.appendWhere(GroupsColumns.CONCRETE_ID + "=" + groupId);
+                break;
+            }
+
+            case GROUPS_SUMMARY: {
+                qb.setTables(Tables.GROUPS_JOIN_PACKAGES_DATA_CONTACTS_AGGREGATES);
+                qb.setProjectionMap(sGroupsSummaryProjectionMap);
+                groupBy = GroupsColumns.CONCRETE_ID;
+                break;
+            }
+
             case AGGREGATION_EXCEPTIONS: {
                 qb.setTables(Tables.AGGREGATION_EXCEPTIONS_JOIN_CONTACTS);
                 qb.setProjectionMap(sAggregationExceptionsProjectionMap);
@@ -1593,7 +1890,7 @@
 
             final SQLiteDatabase db = provider.mOpenHelper.getReadableDatabase();
             final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
-            qb.setTables(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE);
+            qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
             qb.setProjectionMap(sDataContactsAccountsProjectionMap);
             if (contactsIdString != null) {
                 qb.appendWhere(Data.CONTACT_ID + "=" + contactsIdString);
@@ -1765,13 +2062,14 @@
 
         Cursor cursor = null;
         try {
-            cursor = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE, PROJ_DATA_CONTACTS,
-                    Tables.DATA + "." + Data._ID + "=" + dataId, null, null, null, null);
+            cursor = db.query(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES,
+                    Projections.PROJ_DATA_CONTACTS, DataColumns.CONCRETE_ID + "=" + dataId, null,
+                    null, null, null);
             if (cursor.moveToFirst()) {
-                aggId = cursor.getLong(COL_AGGREGATE_ID);
-                packageId = cursor.getLong(COL_PACKAGE_ID);
-                isRestricted = (cursor.getInt(COL_IS_RESTRICTED) == 1);
-                mimeType = cursor.getString(COL_MIMETYPE);
+                aggId = cursor.getLong(Projections.COL_AGGREGATE_ID);
+                packageId = cursor.getLong(Projections.COL_PACKAGE_ID);
+                isRestricted = (cursor.getInt(Projections.COL_IS_RESTRICTED) == 1);
+                mimeType = cursor.getString(Projections.COL_MIMETYPE);
             }
         } finally {
             if (cursor != null) {
diff --git a/src/com/android/providers/contacts2/OpenHelper.java b/src/com/android/providers/contacts2/OpenHelper.java
index b6f2886..d0ce32a 100644
--- a/src/com/android/providers/contacts2/OpenHelper.java
+++ b/src/com/android/providers/contacts2/OpenHelper.java
@@ -35,11 +35,14 @@
 import android.provider.ContactsContract.Accounts;
 import android.provider.ContactsContract.Aggregates;
 import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.CommonDataKinds;
 import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.Presence;
 import android.provider.ContactsContract.RestrictionExceptions;
 import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
 import android.provider.ContactsContract.CommonDataKinds.Im;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 
@@ -60,7 +63,7 @@
 /* package */ class OpenHelper extends SQLiteOpenHelper {
     private static final String TAG = "OpenHelper";
 
-    private static final int DATABASE_VERSION = 34;
+    private static final int DATABASE_VERSION = 38;
     private static final String DATABASE_NAME = "contacts2.db";
     private static final String DATABASE_PRESENCE = "presence_db";
 
@@ -68,14 +71,15 @@
         public static final String ACCOUNTS = "accounts";
         public static final String AGGREGATES = "aggregates";
         public static final String CONTACTS = "contacts";
-        public static final String PACKAGE = "package";
-        public static final String MIMETYPE = "mimetype";
+        public static final String PACKAGES = "packages";
+        public static final String MIMETYPES = "mimetypes";
         public static final String PHONE_LOOKUP = "phone_lookup";
         public static final String NAME_LOOKUP = "name_lookup";
         public static final String AGGREGATION_EXCEPTIONS = "agg_exceptions";
         public static final String RESTRICTION_EXCEPTIONS = "rest_exceptions";
         public static final String CONTACT_OPTIONS = "contact_options";
         public static final String DATA = "data";
+        public static final String GROUPS = "groups";
         public static final String PRESENCE = "presence";
         public static final String NICKNAME_LOOKUP = "nickname_lookup";
 
@@ -83,37 +87,63 @@
                 + "LEFT OUTER JOIN presence ON (aggregates._id = presence.aggregate_id) "
                 + "LEFT OUTER JOIN data ON (primary_phone_id = data._id)";
 
-        public static final String DATA_JOIN_MIMETYPE = "data "
-                + "LEFT OUTER JOIN mimetype ON (data.mimetype_id = mimetype._id)";
+        public static final String DATA_JOIN_MIMETYPES = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id)";
 
         public static final String DATA_JOIN_MIMETYPE_CONTACTS = "data "
                 + "LEFT OUTER JOIN mimetype ON (data.mimetype_id = mimetype._id) "
                 + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id)";
 
-        public static final String DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE = "data "
-                + "LEFT OUTER JOIN mimetype ON (data.mimetype_id = mimetype._id) "
-                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
-                + "LEFT OUTER JOIN package ON (contacts.package_id = package._id)";
+        public static final String DATA_JOIN_CONTACTS_GROUPS = "data "
+                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id)"
+                + "LEFT OUTER JOIN groups ON (groups._id = data." + GroupMembership.GROUP_ROW_ID
+                + ")";
 
-        public static final String DATA_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES = "data "
-                + "LEFT OUTER JOIN mimetype ON (data.mimetype_id = mimetype._id) "
+        public static final String DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
                 + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
-                + "LEFT OUTER JOIN package ON (contacts.package_id = package._id) "
+                + "LEFT OUTER JOIN packages ON (contacts.package_id = packages._id)";
+
+        public static final String DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
+                + "LEFT OUTER JOIN packages ON (contacts.package_id = packages._id) "
+                + "LEFT OUTER JOIN aggregates ON (contacts.aggregate_id = aggregates._id)";
+
+        public static final String DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_GROUPS = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
+                + "LEFT OUTER JOIN packages ON (contacts.package_id = packages._id) "
+                + "LEFT OUTER JOIN groups ON (groups._id = data." + GroupMembership.GROUP_ROW_ID
+                + ")";
+
+        public static final String GROUPS_JOIN_PACKAGES = "groups "
+                + "LEFT OUTER JOIN packages ON (groups.package_id = packages._id)";
+
+        public static final String DATA_JOIN_MIMETYPES_CONTACTS_AGGREGATES = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
+                + "LEFT OUTER JOIN aggregates ON (contacts.aggregate_id = aggregates._id)";
+
+        public static final String GROUPS_JOIN_PACKAGES_DATA_CONTACTS_AGGREGATES = "groups "
+                + "LEFT OUTER JOIN packages ON (groups.package_id = packages._id) "
+                + "LEFT OUTER JOIN data ON (groups._id = data." + GroupMembership.GROUP_ROW_ID
+                + ") " + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._id) "
                 + "LEFT OUTER JOIN aggregates ON (contacts.aggregate_id = aggregates._id)";
 
         public static final String ACTIVITIES = "activities";
 
-        public static final String ACTIVITIES_JOIN_MIMETYPE = "activities "
-                + "LEFT OUTER JOIN mimetype ON (activities.mimetype_id = mimetype._id)";
+        public static final String ACTIVITIES_JOIN_MIMETYPES = "activities "
+                + "LEFT OUTER JOIN mimetypes ON (activities.mimetype_id = mimetypes._id)";
 
-        public static final String ACTIVITIES_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES = "activities "
-                + "LEFT OUTER JOIN mimetype ON (activities.mimetype_id = mimetype._id) "
+        public static final String ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES = "activities "
+                + "LEFT OUTER JOIN mimetypes ON (activities.mimetype_id = mimetypes._id) "
                 + "LEFT OUTER JOIN contacts ON (activities.author_contact_id = contacts._id) "
-                + "LEFT OUTER JOIN package ON (contacts.package_id = package._id) "
+                + "LEFT OUTER JOIN packages ON (contacts.package_id = packages._id) "
                 + "LEFT OUTER JOIN aggregates ON (contacts.aggregate_id = aggregates._id)";
 
-        public static final String CONTACTS_JOIN_PACKAGE_ACCOUNTS = "contacts "
-                + "LEFT OUTER JOIN package ON (contacts.package_id = package._id) "
+        public static final String CONTACTS_JOIN_PACKAGES_ACCOUNTS = "contacts "
+                + "LEFT OUTER JOIN packages ON (contacts.package_id = packages._id) "
                 + "LEFT OUTER JOIN accounts ON (contacts.accounts_id = accounts._id)";
 
         public static final String NAME_LOOKUP_JOIN_CONTACTS = "name_lookup "
@@ -134,11 +164,27 @@
     }
 
     public interface Clauses {
-        public static final String WHERE_IM_MATCHES = MimetypeColumns.MIMETYPE + "=" + Im.MIMETYPE
+        public static final String WHERE_IM_MATCHES = MimetypesColumns.MIMETYPE + "=" + Im.MIMETYPE
                 + " AND " + Im.PROTOCOL + "=? AND " + Im.DATA + "=?";
 
-        public static final String WHERE_EMAIL_MATCHES = MimetypeColumns.MIMETYPE + "="
+        public static final String WHERE_EMAIL_MATCHES = MimetypesColumns.MIMETYPE + "="
                 + Email.MIMETYPE + " AND " + Email.DATA + "=?";
+
+        public static final String MIMETYPE_IS_GROUP_MEMBERSHIP = MimetypesColumns.CONCRETE_MIMETYPE
+                + "='" + GroupMembership.CONTENT_ITEM_TYPE + "'";
+
+        public static final String BELONGS_TO_GROUP = DataColumns.CONCRETE_GROUP_ID + "="
+                + GroupsColumns.CONCRETE_ID;
+
+        public static final String HAS_PRIMARY_PHONE = "("
+                + AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID + " IS NOT NULL OR "
+                + AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID + " IS NOT NULL)";
+
+        // TODO: add in check against package_visible
+        public static final String IN_VISIBLE_GROUP = "SELECT MIN(COUNT(" + DataColumns.CONCRETE_ID
+                + "),1) FROM " + Tables.DATA_JOIN_CONTACTS_GROUPS + " WHERE "
+                + DataColumns.MIMETYPE_ID + "=? AND " + Contacts.AGGREGATE_ID + "="
+                + AggregatesColumns.CONCRETE_ID + " AND " + Groups.GROUP_VISIBLE + "=1";
     }
 
     public interface AggregatesColumns {
@@ -151,14 +197,28 @@
         public static final String FALLBACK_PRIMARY_EMAIL_ID = "fallback_email_id";
 
         public static final String SINGLE_RESTRICTED_PACKAGE_ID = "single_restricted_package_id";
+
+        public static final String CONCRETE_ID = Tables.AGGREGATES + "." + BaseColumns._ID;
     }
 
     public interface ContactsColumns {
         public static final String PACKAGE_ID = "package_id";
+
+        public static final String CONCRETE_ID = Tables.CONTACTS + "." + BaseColumns._ID;
     }
 
     public interface DataColumns {
         public static final String MIMETYPE_ID = "mimetype_id";
+
+        public static final String CONCRETE_ID = Tables.DATA + "." + BaseColumns._ID;
+        public static final String CONCRETE_CONTACT_ID = Tables.DATA + "." + Data.CONTACT_ID;
+        public static final String CONCRETE_GROUP_ID = Tables.DATA + "."
+                + GroupMembership.GROUP_ROW_ID;
+    }
+
+    public interface GroupsColumns {
+        public static final String CONCRETE_ID = Tables.GROUPS + "." + BaseColumns._ID;
+        public static final String CONCRETE_PACKAGE_ID = Tables.GROUPS + "." + Groups.PACKAGE_ID;
     }
 
     public interface ActivitiesColumns {
@@ -203,14 +263,17 @@
         }
     }
 
-    public interface PackageColumns {
+    public interface PackagesColumns {
         public static final String _ID = BaseColumns._ID;
         public static final String PACKAGE = "package";
     }
 
-    /* package */ interface MimetypeColumns {
+    public interface MimetypesColumns {
         public static final String _ID = BaseColumns._ID;
         public static final String MIMETYPE = "mimetype";
+
+        public static final String CONCRETE_ID = Tables.MIMETYPES + "." + BaseColumns._ID;
+        public static final String CONCRETE_MIMETYPE = Tables.MIMETYPES + "." + MIMETYPE;
     }
 
     public interface AggregationExceptionColumns {
@@ -263,6 +326,10 @@
     private final RestrictionExceptionsCache mCache;
     private HashMap<String, String[]> mNicknameClusterCache;
 
+    /** Compiled statements for updating {@link Aggregates#IN_VISIBLE_GROUP}. */
+    private SQLiteStatement mVisibleAllUpdate;
+    private SQLiteStatement mVisibleSpecificUpdate;
+
 
     private static OpenHelper sSingleton = null;
 
@@ -289,28 +356,35 @@
     @Override
     public void onOpen(SQLiteDatabase db) {
         // Create compiled statements for package and mimetype lookups
-        mMimetypeQuery = db.compileStatement("SELECT " + MimetypeColumns._ID + " FROM "
-                + Tables.MIMETYPE + " WHERE " + MimetypeColumns.MIMETYPE + "=?");
-        mPackageQuery = db.compileStatement("SELECT " + PackageColumns._ID + " FROM "
-                + Tables.PACKAGE + " WHERE " + PackageColumns.PACKAGE + "=?");
+        mMimetypeQuery = db.compileStatement("SELECT " + MimetypesColumns._ID + " FROM "
+                + Tables.MIMETYPES + " WHERE " + MimetypesColumns.MIMETYPE + "=?");
+        mPackageQuery = db.compileStatement("SELECT " + PackagesColumns._ID + " FROM "
+                + Tables.PACKAGES + " WHERE " + PackagesColumns.PACKAGE + "=?");
         mAggregateIdQuery = db.compileStatement("SELECT " + Contacts.AGGREGATE_ID + " FROM "
                 + Tables.CONTACTS + " WHERE " + Contacts._ID + "=?");
         mAggregateIdUpdate = db.compileStatement("UPDATE " + Tables.CONTACTS + " SET "
                 + Contacts.AGGREGATE_ID + "=?" + " WHERE " + Contacts._ID + "=?");
-        mMimetypeInsert = db.compileStatement("INSERT INTO " + Tables.MIMETYPE + "("
-                + MimetypeColumns.MIMETYPE + ") VALUES (?)");
-        mPackageInsert = db.compileStatement("INSERT INTO " + Tables.PACKAGE + "("
-                + PackageColumns.PACKAGE + ") VALUES (?)");
+        mMimetypeInsert = db.compileStatement("INSERT INTO " + Tables.MIMETYPES + "("
+                + MimetypesColumns.MIMETYPE + ") VALUES (?)");
+        mPackageInsert = db.compileStatement("INSERT INTO " + Tables.PACKAGES + "("
+                + PackagesColumns.PACKAGE + ") VALUES (?)");
 
-        mDataMimetypeQuery = db.compileStatement("SELECT " + MimetypeColumns.MIMETYPE + " FROM "
-                + Tables.DATA_JOIN_MIMETYPE + " WHERE " + Tables.DATA + "." + Data._ID + "=?");
-        mActivitiesMimetypeQuery = db.compileStatement("SELECT " + MimetypeColumns.MIMETYPE
-                + " FROM " + Tables.ACTIVITIES_JOIN_MIMETYPE + " WHERE " + Tables.ACTIVITIES + "."
+        mDataMimetypeQuery = db.compileStatement("SELECT " + MimetypesColumns.MIMETYPE + " FROM "
+                + Tables.DATA_JOIN_MIMETYPES + " WHERE " + Tables.DATA + "." + Data._ID + "=?");
+        mActivitiesMimetypeQuery = db.compileStatement("SELECT " + MimetypesColumns.MIMETYPE
+                + " FROM " + Tables.ACTIVITIES_JOIN_MIMETYPES + " WHERE " + Tables.ACTIVITIES + "."
                 + Activities._ID + "=?");
         mNameLookupInsert = db.compileStatement("INSERT INTO " + Tables.NAME_LOOKUP + "("
                 + NameLookupColumns.CONTACT_ID + "," + NameLookupColumns.NAME_TYPE + ","
                 + NameLookupColumns.NORMALIZED_NAME + ") VALUES (?,?,?)");
 
+        final String visibleUpdate = "UPDATE " + Tables.AGGREGATES + " SET "
+                + Aggregates.IN_VISIBLE_GROUP + "= (" + Clauses.IN_VISIBLE_GROUP + ")";
+
+        mVisibleAllUpdate = db.compileStatement(visibleUpdate);
+        mVisibleSpecificUpdate = db.compileStatement(visibleUpdate + " WHERE "
+                + AggregatesColumns.CONCRETE_ID + "=?");
+
         // Make sure we have an in-memory presence table
         final String tableName = DATABASE_PRESENCE + "." + Tables.PRESENCE;
         final String indexName = DATABASE_PRESENCE + ".presenceIndex";
@@ -387,15 +461,15 @@
        ");");
 
         // Package name mapping table
-        db.execSQL("CREATE TABLE " + Tables.PACKAGE + " (" +
-                PackageColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
-                PackageColumns.PACKAGE + " TEXT NOT NULL" +
+        db.execSQL("CREATE TABLE " + Tables.PACKAGES + " (" +
+                PackagesColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                PackagesColumns.PACKAGE + " TEXT NOT NULL" +
         ");");
 
-        // Mime-type mapping table
-        db.execSQL("CREATE TABLE " + Tables.MIMETYPE + " (" +
-                MimetypeColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
-                MimetypeColumns.MIMETYPE + " TEXT NOT NULL" +
+        // Mimetype mapping table
+        db.execSQL("CREATE TABLE " + Tables.MIMETYPES + " (" +
+                MimetypesColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                MimetypesColumns.MIMETYPE + " TEXT NOT NULL" +
         ");");
 
         // Public generic data table
@@ -504,6 +578,17 @@
                 NicknameLookupColumns.CLUSTER +
         ");");
 
+        // Groups table
+        db.execSQL("CREATE TABLE " + Tables.GROUPS + " (" +
+                Groups._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                Groups.PACKAGE_ID + " INTEGER REFERENCES package(_id) NOT NULL," +
+                Groups.ACCOUNTS_ID + " INTEGER REFERENCES accounts(_id)," +
+                Groups.SOURCE_ID + " TEXT," +
+                Groups.TITLE + " TEXT," +
+                Groups.TITLE_RESOURCE + " INTEGER," +
+                Groups.GROUP_VISIBLE + " INTEGER" +
+        ");");
+
         db.execSQL("CREATE TABLE IF NOT EXISTS " + Tables.AGGREGATION_EXCEPTIONS + " (" +
                 AggregationExceptionColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
                 AggregationExceptions.TYPE + " INTEGER NOT NULL, " +
@@ -560,12 +645,13 @@
         db.execSQL("DROP TABLE IF EXISTS " + Tables.ACCOUNTS + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.AGGREGATES + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.CONTACTS + ";");
-        db.execSQL("DROP TABLE IF EXISTS " + Tables.PACKAGE + ";");
-        db.execSQL("DROP TABLE IF EXISTS " + Tables.MIMETYPE + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.PACKAGES + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.MIMETYPES + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.DATA + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.PHONE_LOOKUP + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.NAME_LOOKUP + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.NICKNAME_LOOKUP + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.GROUPS + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.RESTRICTION_EXCEPTIONS + ";");
         db.execSQL("DROP TABLE IF EXISTS " + Tables.ACTIVITIES + ";");
 
@@ -589,6 +675,7 @@
         db.execSQL("DELETE FROM " + Tables.DATA + ";");
         db.execSQL("DELETE FROM " + Tables.PHONE_LOOKUP + ";");
         db.execSQL("DELETE FROM " + Tables.NAME_LOOKUP + ";");
+        db.execSQL("DELETE FROM " + Tables.GROUPS + ";");
         db.execSQL("DELETE FROM " + Tables.AGGREGATION_EXCEPTIONS + ";");
         db.execSQL("DELETE FROM " + Tables.RESTRICTION_EXCEPTIONS + ";");
         db.execSQL("DELETE FROM " + Tables.ACTIVITIES + ";");
@@ -653,7 +740,7 @@
     }
 
     /**
-     * Convert a package name into an integer, using {@link Tables#PACKAGE} for
+     * Convert a package name into an integer, using {@link Tables#PACKAGES} for
      * lookups and possible allocation of new IDs as needed.
      */
     public long getPackageId(String packageName) {
@@ -663,7 +750,7 @@
     }
 
     /**
-     * Convert a mime-type into an integer, using {@link Tables#MIMETYPE} for
+     * Convert a mimetype into an integer, using {@link Tables#MIMETYPES} for
      * lookups and possible allocation of new IDs as needed.
      */
     public long getMimeTypeId(String mimetype) {
@@ -673,7 +760,7 @@
     }
 
     /**
-     * Find the mime-type for the given {@link Data#_ID}.
+     * Find the mimetype for the given {@link Data#_ID}.
      */
     public String getDataMimeType(long dataId) {
         // Make sure compiled statements are ready by opening database
@@ -707,6 +794,25 @@
     }
 
     /**
+     * Update {@link Aggregates#IN_VISIBLE_GROUP} for all aggregates.
+     */
+    public void updateAllVisible() {
+        final long groupMembershipMimetypeId = getMimeTypeId(GroupMembership.CONTENT_ITEM_TYPE);
+        mVisibleAllUpdate.bindLong(1, groupMembershipMimetypeId);
+        mVisibleAllUpdate.execute();
+    }
+
+    /**
+     * Update {@link Aggregates#IN_VISIBLE_GROUP} for a specific aggregate.
+     */
+    public void updateAggregateVisible(long aggId) {
+        final long groupMembershipMimetypeId = getMimeTypeId(GroupMembership.CONTENT_ITEM_TYPE);
+        mVisibleSpecificUpdate.bindLong(1, groupMembershipMimetypeId);
+        mVisibleSpecificUpdate.bindLong(2, aggId);
+        mVisibleSpecificUpdate.execute();
+    }
+
+    /**
      * Updates the aggregate ID for the specified contact.
      */
     public void setAggregateId(long contactId, long aggregateId) {
@@ -747,7 +853,7 @@
         tables.append("contacts, (SELECT data_id FROM phone_lookup "
                 + "WHERE (phone_lookup.normalized_number GLOB '");
         tables.append(normalizedNumber);
-        tables.append("*')) AS lookup, " + Tables.DATA_JOIN_MIMETYPE);
+        tables.append("*')) AS lookup, " + Tables.DATA_JOIN_MIMETYPES);
         qb.setTables(tables.toString());
         qb.appendWhere("lookup.data_id=data._id AND data.contact_id=contacts._id AND ");
         qb.appendWhere("PHONE_NUMBERS_EQUAL(data." + Phone.NUMBER + ", ");
@@ -1018,7 +1124,9 @@
             if (matchesClause != null) {
                 return matchesClause.getQueryClause(column, mBuilder);
             } else {
-                return null;
+                // When no matching clause found, return 0 to provide a false
+                // value for the query string.
+                return "0";
             }
         }
 
diff --git a/src/com/android/providers/contacts2/SocialProvider.java b/src/com/android/providers/contacts2/SocialProvider.java
index 4dfbdef..b8a74e0 100644
--- a/src/com/android/providers/contacts2/SocialProvider.java
+++ b/src/com/android/providers/contacts2/SocialProvider.java
@@ -326,7 +326,7 @@
         final int match = sUriMatcher.match(uri);
         switch (match) {
             case ACTIVITIES: {
-                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
                 break;
             }
@@ -334,7 +334,7 @@
             case ACTIVITIES_ID: {
                 // TODO: enforce that caller has read access to this data
                 long activityId = ContentUris.parseId(uri);
-                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
                 qb.appendWhere(Activities._ID + "=" + activityId);
                 break;
@@ -342,7 +342,7 @@
 
             case ACTIVITIES_AUTHORED_BY: {
                 long contactId = ContentUris.parseId(uri);
-                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
                 qb.appendWhere(Activities.AUTHOR_CONTACT_ID + "=" + contactId);
                 break;
@@ -350,7 +350,7 @@
 
             case AGGREGATE_STATUS_ID: {
                 long aggId = ContentUris.parseId(uri);
-                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPE_CONTACTS_PACKAGE_AGGREGATES);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
                 qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
 
                 // Latest status of an aggregate is any top-level status
diff --git a/tests/src/com/android/providers/contacts2/ContactsActor.java b/tests/src/com/android/providers/contacts2/ContactsActor.java
index 22e2bf3..c831372 100644
--- a/tests/src/com/android/providers/contacts2/ContactsActor.java
+++ b/tests/src/com/android/providers/contacts2/ContactsActor.java
@@ -16,12 +16,23 @@
 
 package com.android.providers.contacts2;
 
+import android.content.ContentUris;
+import android.content.ContentValues;
 import android.content.Context;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.res.Resources;
+import android.database.Cursor;
+import android.net.Uri;
 import android.os.Binder;
+import android.provider.BaseColumns;
 import android.provider.ContactsContract;
+import android.provider.Contacts.Phones;
+import android.provider.ContactsContract.Aggregates;
+import android.provider.ContactsContract.CommonDataKinds;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RestrictionExceptions;
 import android.test.IsolatedContext;
 import android.test.RenamingDelegatingContext;
 import android.test.mock.MockContentResolver;
@@ -145,4 +156,141 @@
             return info;
         }
     }
+
+    public long createContact(boolean isRestricted, String name) {
+        long contactId = createContact(isRestricted);
+        createName(contactId, name);
+        return contactId;
+    }
+
+    public long createContact(boolean isRestricted) {
+        final ContentValues values = new ContentValues();
+        values.put(Contacts.PACKAGE, packageName);
+        if (isRestricted) {
+            values.put(Contacts.IS_RESTRICTED, 1);
+        }
+
+        Uri contactUri = resolver.insert(Contacts.CONTENT_URI, values);
+        return ContentUris.parseId(contactUri);
+    }
+
+    public long createName(long contactId, String name) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.IS_PRIMARY, 1);
+        values.put(Data.IS_SUPER_PRIMARY, 1);
+        values.put(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.StructuredName.FAMILY_NAME, name);
+        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
+                contactId), Contacts.Data.CONTENT_DIRECTORY);
+        Uri dataUri = resolver.insert(insertUri, values);
+        return ContentUris.parseId(dataUri);
+    }
+
+    public long createPhone(long contactId, String phoneNumber) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.IS_PRIMARY, 1);
+        values.put(Data.IS_SUPER_PRIMARY, 1);
+        values.put(Data.MIMETYPE, Phones.CONTENT_ITEM_TYPE);
+        values.put(ContactsContract.CommonDataKinds.Phone.NUMBER, phoneNumber);
+        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
+                contactId), Contacts.Data.CONTENT_DIRECTORY);
+        Uri dataUri = resolver.insert(insertUri, values);
+        return ContentUris.parseId(dataUri);
+    }
+
+    public void updateException(String packageProvider, String packageClient, boolean allowAccess) {
+        final ContentValues values = new ContentValues();
+        values.put(RestrictionExceptions.PACKAGE_PROVIDER, packageProvider);
+        values.put(RestrictionExceptions.PACKAGE_CLIENT, packageClient);
+        values.put(RestrictionExceptions.ALLOW_ACCESS, allowAccess ? 1 : 0);
+        resolver.update(RestrictionExceptions.CONTENT_URI, values, null, null);
+    }
+
+    public long getAggregateForContact(long contactId) {
+        Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
+        final Cursor cursor = resolver.query(contactUri, Projections.PROJ_CONTACTS, null,
+                null, null);
+        if (!cursor.moveToFirst()) {
+            cursor.close();
+            throw new RuntimeException("Contact didn't have an aggregate");
+        }
+        final long aggId = cursor.getLong(Projections.COL_CONTACTS_AGGREGATE);
+        cursor.close();
+        return aggId;
+    }
+
+    public int getDataCountForAggregate(long aggId) {
+        Uri contactUri = Uri.withAppendedPath(ContentUris.withAppendedId(Aggregates.CONTENT_URI,
+                aggId), Aggregates.Data.CONTENT_DIRECTORY);
+        final Cursor cursor = resolver.query(contactUri, Projections.PROJ_ID, null, null,
+                null);
+        final int count = cursor.getCount();
+        cursor.close();
+        return count;
+    }
+
+    public void setSuperPrimaryPhone(long dataId) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.IS_PRIMARY, 1);
+        values.put(Data.IS_SUPER_PRIMARY, 1);
+        Uri updateUri = ContentUris.withAppendedId(Data.CONTENT_URI, dataId);
+        resolver.update(updateUri, values, null, null);
+    }
+
+    public long getPrimaryPhoneId(long aggId) {
+        Uri aggUri = ContentUris.withAppendedId(Aggregates.CONTENT_URI, aggId);
+        final Cursor cursor = resolver.query(aggUri, Projections.PROJ_AGGREGATES, null,
+                null, null);
+        long primaryPhoneId = -1;
+        if (cursor.moveToFirst()) {
+            primaryPhoneId = cursor.getLong(Projections.COL_AGGREGATES_PRIMARY_PHONE_ID);
+        }
+        cursor.close();
+        return primaryPhoneId;
+    }
+
+    public long createGroup(String groupName) {
+        final ContentValues values = new ContentValues();
+        values.put(ContactsContract.Groups.PACKAGE, packageName);
+        values.put(ContactsContract.Groups.TITLE, groupName);
+        Uri groupUri = resolver.insert(ContactsContract.Groups.CONTENT_URI, values);
+        return ContentUris.parseId(groupUri);
+    }
+
+    public long createGroupMembership(long contactId, long groupId) {
+        final ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE);
+        values.put(CommonDataKinds.GroupMembership.GROUP_ROW_ID, groupId);
+        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
+                contactId), Contacts.Data.CONTENT_DIRECTORY);
+        Uri dataUri = resolver.insert(insertUri, values);
+        return ContentUris.parseId(dataUri);
+    }
+
+    /**
+     * Various internal database projections.
+     */
+    private interface Projections {
+        static final String[] PROJ_ID = new String[] {
+                BaseColumns._ID,
+        };
+
+        static final int COL_ID = 0;
+
+        static final String[] PROJ_CONTACTS = new String[] {
+                Contacts.AGGREGATE_ID
+        };
+
+        static final int COL_CONTACTS_AGGREGATE = 0;
+
+        static final String[] PROJ_AGGREGATES = new String[] {
+                Aggregates.PRIMARY_PHONE_ID
+        };
+
+        static final int COL_AGGREGATES_PRIMARY_PHONE_ID = 0;
+
+    }
 }
diff --git a/tests/src/com/android/providers/contacts2/GroupsTest.java b/tests/src/com/android/providers/contacts2/GroupsTest.java
new file mode 100644
index 0000000..326ae6d
--- /dev/null
+++ b/tests/src/com/android/providers/contacts2/GroupsTest.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2009 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.providers.contacts2;
+
+import static com.android.providers.contacts2.ContactsActor.PACKAGE_GREY;
+
+import android.database.Cursor;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
+import android.test.AndroidTestCase;
+import android.test.mock.MockContentResolver;
+import android.test.suitebuilder.annotation.LargeTest;
+
+/**
+ * Unit tests for {@link Groups} and {@link GroupMembership}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts2.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class GroupsTest extends AndroidTestCase {
+
+    private ContactsActor mActor;
+    private MockContentResolver mResolver;
+
+    public GroupsTest() {
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mActor = new ContactsActor(getContext(), PACKAGE_GREY);
+        mResolver = mActor.resolver;
+    }
+
+    private static final String GROUP_GREY = "Grey";
+    private static final String GROUP_RED = "Red";
+    private static final String GROUP_GREEN = "Green";
+    private static final String GROUP_BLUE = "Blue";
+
+    private static final String PERSON_ALPHA = "Alpha";
+    private static final String PERSON_BRAVO = "Bravo";
+    private static final String PERSON_CHARLIE = "Charlie";
+    private static final String PERSON_DELTA = "Delta";
+
+    private static final String PHONE_ALPHA = "555-1111";
+    private static final String PHONE_BRAVO_1 = "555-2222";
+    private static final String PHONE_BRAVO_2 = "555-3333";
+    private static final String PHONE_CHARLIE_1 = "555-4444";
+    private static final String PHONE_CHARLIE_2 = "555-5555";
+
+    public void testGroupSummary() {
+
+        // Clear any existing data before starting
+        mActor.provider.wipeData();
+
+        // Create a handful of groups
+        long groupGrey = mActor.createGroup(GROUP_GREY);
+        long groupRed = mActor.createGroup(GROUP_RED);
+        long groupGreen = mActor.createGroup(GROUP_GREEN);
+        long groupBlue = mActor.createGroup(GROUP_BLUE);
+
+        // Create a handful of contacts
+        long contactAlpha = mActor.createContact(false, PERSON_ALPHA);
+        long contactBravo = mActor.createContact(false, PERSON_BRAVO);
+        long contactCharlie = mActor.createContact(false, PERSON_CHARLIE);
+        long contactCharlieDupe = mActor.createContact(false, PERSON_CHARLIE);
+        long contactDelta = mActor.createContact(false, PERSON_DELTA);
+
+        // Make sure that Charlie was aggregated
+        {
+            long aggCharlie = mActor.getAggregateForContact(contactCharlie);
+            long aggCharlieDupe = mActor.getAggregateForContact(contactCharlieDupe);
+            assertTrue("Didn't aggregate two contacts with identical names",
+                    (aggCharlie == aggCharlieDupe));
+        }
+
+        // Add phone numbers to specific contacts
+        mActor.createPhone(contactAlpha, PHONE_ALPHA);
+        mActor.createPhone(contactBravo, PHONE_BRAVO_1);
+        mActor.createPhone(contactBravo, PHONE_BRAVO_2);
+        mActor.createPhone(contactCharlie, PHONE_CHARLIE_1);
+        mActor.createPhone(contactCharlieDupe, PHONE_CHARLIE_2);
+
+        // Add contacts to various mixture of groups. Grey will have all
+        // contacts, Red only with phone numbers, Green with no phones, and Blue
+        // with no contacts at all.
+        mActor.createGroupMembership(contactAlpha, groupGrey);
+        mActor.createGroupMembership(contactBravo, groupGrey);
+        mActor.createGroupMembership(contactCharlie, groupGrey);
+        mActor.createGroupMembership(contactDelta, groupGrey);
+
+        mActor.createGroupMembership(contactAlpha, groupRed);
+        mActor.createGroupMembership(contactBravo, groupRed);
+        mActor.createGroupMembership(contactCharlie, groupRed);
+
+        mActor.createGroupMembership(contactDelta, groupGreen);
+
+        // Walk across groups summary cursor and verify returned counts.
+        final Cursor cursor = mActor.resolver.query(Groups.CONTENT_SUMMARY_URI,
+                Projections.PROJ_SUMMARY, null, null, null);
+
+        // Require that each group has a summary row
+        assertTrue("Didn't return summary for all groups", (cursor.getCount() == 4));
+
+        while (cursor.moveToNext()) {
+            final long groupId = cursor.getLong(Projections.COL_ID);
+            final int summaryCount = cursor.getInt(Projections.COL_SUMMARY_COUNT);
+            final int summaryWithPhones = cursor.getInt(Projections.COL_SUMMARY_WITH_PHONES);
+
+            if (groupId == groupGrey) {
+                // Grey should have four aggregates, three with phones.
+                assertTrue("Incorrect Grey count", (summaryCount == 4));
+                assertTrue("Incorrect Grey with phones count", (summaryWithPhones == 3));
+            } else if (groupId == groupRed) {
+                // Red should have 3 aggregates, all with phones.
+                assertTrue("Incorrect Red count", (summaryCount == 3));
+                assertTrue("Incorrect Red with phones count", (summaryWithPhones == 3));
+            } else if (groupId == groupGreen) {
+                // Green should have 1 aggregate, none with phones.
+                assertTrue("Incorrect Green count", (summaryCount == 1));
+                assertTrue("Incorrect Green with phones count", (summaryWithPhones == 0));
+            } else if (groupId == groupBlue) {
+                // Blue should have no contacts.
+                assertTrue("Incorrect Blue count", (summaryCount == 0));
+                assertTrue("Incorrect Blue with phones count", (summaryWithPhones == 0));
+            } else {
+                fail("Unrecognized group in summary cursor");
+            }
+        }
+
+    }
+
+    private interface Projections {
+        public static final String[] PROJ_SUMMARY = new String[] {
+            Groups._ID,
+            Groups.SUMMARY_COUNT,
+            Groups.SUMMARY_WITH_PHONES,
+        };
+
+        public static final int COL_ID = 0;
+        public static final int COL_SUMMARY_COUNT = 1;
+        public static final int COL_SUMMARY_WITH_PHONES = 2;
+    }
+
+}
diff --git a/tests/src/com/android/providers/contacts2/RestrictionExceptionsTest.java b/tests/src/com/android/providers/contacts2/RestrictionExceptionsTest.java
index 1e3f554..4075d11 100644
--- a/tests/src/com/android/providers/contacts2/RestrictionExceptionsTest.java
+++ b/tests/src/com/android/providers/contacts2/RestrictionExceptionsTest.java
@@ -22,21 +22,16 @@
 import static com.android.providers.contacts2.ContactsActor.PACKAGE_RED;
 
 import android.content.ContentUris;
-import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
 import android.net.Uri;
 import android.provider.BaseColumns;
-import android.provider.ContactsContract;
-import android.provider.Contacts.Phones;
 import android.provider.ContactsContract.Aggregates;
-import android.provider.ContactsContract.CommonDataKinds;
 import android.provider.ContactsContract.Contacts;
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.RestrictionExceptions;
 import android.test.AndroidTestCase;
 import android.test.suitebuilder.annotation.LargeTest;
-import android.util.Log;
 
 /**
  * Unit tests for {@link RestrictionExceptions}.
@@ -50,7 +45,6 @@
 @LargeTest
 public class RestrictionExceptionsTest extends AndroidTestCase {
     private static final String TAG = "RestrictionExceptionsTest";
-    private static final boolean LOGD = false;
 
     private static ContactsActor mGrey;
     private static ContactsActor mRed;
@@ -91,62 +85,62 @@
         mGrey.provider.wipeData();
 
         // Grey creates an unprotected contact
-        long greyContact = createContact(mGrey, false);
-        long greyData = createPhone(mGrey, greyContact, PHONE_GREY);
-        long greyAgg = getAggregateForContact(mGrey, greyContact);
+        long greyContact = mGrey.createContact(false);
+        long greyData = mGrey.createPhone(greyContact, PHONE_GREY);
+        long greyAgg = mGrey.getAggregateForContact(greyContact);
 
         // Assert that both Grey and Blue can read contact
         assertTrue("Owner of unrestricted contact unable to read",
-                (getDataCountForAggregate(mGrey, greyAgg) == 1));
+                (mGrey.getDataCountForAggregate(greyAgg) == 1));
         assertTrue("Non-owner of unrestricted contact unable to read",
-                (getDataCountForAggregate(mBlue, greyAgg) == 1));
+                (mBlue.getDataCountForAggregate(greyAgg) == 1));
 
         // Red grants protected access to itself
-        updateException(mRed, PACKAGE_RED, PACKAGE_RED, true);
+        mRed.updateException(PACKAGE_RED, PACKAGE_RED, true);
 
         // Red creates a protected contact
-        long redContact = createContact(mRed, true);
-        long redData = createPhone(mRed, redContact, PHONE_RED);
-        long redAgg = getAggregateForContact(mRed, redContact);
+        long redContact = mRed.createContact(true);
+        long redData = mRed.createPhone(redContact, PHONE_RED);
+        long redAgg = mRed.getAggregateForContact(redContact);
 
         // Assert that only Red can read contact
         assertTrue("Owner of restricted contact unable to read",
-                (getDataCountForAggregate(mRed, redAgg) == 1));
+                (mRed.getDataCountForAggregate(redAgg) == 1));
         assertTrue("Non-owner of restricted contact able to read",
-                (getDataCountForAggregate(mBlue, redAgg) == 0));
+                (mBlue.getDataCountForAggregate(redAgg) == 0));
         assertTrue("Non-owner of restricted contact able to read",
-                (getDataCountForAggregate(mGreen, redAgg) == 0));
+                (mGreen.getDataCountForAggregate(redAgg) == 0));
 
         try {
             // Blue tries to grant an exception for Red data, which should throw
             // exception. If it somehow worked, fail this test.
-            updateException(mBlue, PACKAGE_RED, PACKAGE_BLUE, true);
+            mBlue.updateException(PACKAGE_RED, PACKAGE_BLUE, true);
             fail("Non-owner able to grant restriction exception");
 
         } catch (RuntimeException e) {
         }
 
         // Red grants exception to Blue for contact
-        updateException(mRed, PACKAGE_RED, PACKAGE_BLUE, true);
+        mRed.updateException(PACKAGE_RED, PACKAGE_BLUE, true);
 
         // Both Blue and Red can read Red contact, but still not Green
         assertTrue("Owner of restricted contact unable to read",
-                (getDataCountForAggregate(mRed, redAgg) == 1));
+                (mRed.getDataCountForAggregate(redAgg) == 1));
         assertTrue("Non-owner with restriction exception unable to read",
-                (getDataCountForAggregate(mBlue, redAgg) == 1));
+                (mBlue.getDataCountForAggregate(redAgg) == 1));
         assertTrue("Non-owner of restricted contact able to read",
-                (getDataCountForAggregate(mGreen, redAgg) == 0));
+                (mGreen.getDataCountForAggregate(redAgg) == 0));
 
         // Red revokes exception to Blue
-        updateException(mRed, PACKAGE_RED, PACKAGE_BLUE, false);
+        mRed.updateException(PACKAGE_RED, PACKAGE_BLUE, false);
 
         // Assert that only Red can read contact
         assertTrue("Owner of restricted contact unable to read",
-                (getDataCountForAggregate(mRed, redAgg) == 1));
+                (mRed.getDataCountForAggregate(redAgg) == 1));
         assertTrue("Non-owner of restricted contact able to read",
-                (getDataCountForAggregate(mBlue, redAgg) == 0));
+                (mBlue.getDataCountForAggregate(redAgg) == 0));
         assertTrue("Non-owner of restricted contact able to read",
-                (getDataCountForAggregate(mGreen, redAgg) == 0));
+                (mGreen.getDataCountForAggregate(redAgg) == 0));
 
     }
 
@@ -161,31 +155,31 @@
         mGrey.provider.wipeData();
 
         // Red grants exceptions to itself and Grey
-        updateException(mRed, PACKAGE_RED, PACKAGE_RED, true);
-        updateException(mRed, PACKAGE_RED, PACKAGE_GREY, true);
+        mRed.updateException(PACKAGE_RED, PACKAGE_RED, true);
+        mRed.updateException(PACKAGE_RED, PACKAGE_GREY, true);
 
         // Red creates a protected contact
-        long redContact = createContact(mRed, true);
-        long redName = createName(mRed, redContact, GENERIC_NAME);
-        long redPhone = createPhone(mRed, redContact, PHONE_RED);
+        long redContact = mRed.createContact(true);
+        long redName = mRed.createName(redContact, GENERIC_NAME);
+        long redPhone = mRed.createPhone(redContact, PHONE_RED);
 
         // Blue grants exceptions to itself and Grey
-        updateException(mBlue, PACKAGE_BLUE, PACKAGE_BLUE, true);
-        updateException(mBlue, PACKAGE_BLUE, PACKAGE_GREY, true);
+        mBlue.updateException(PACKAGE_BLUE, PACKAGE_BLUE, true);
+        mBlue.updateException(PACKAGE_BLUE, PACKAGE_GREY, true);
 
         // Blue creates a protected contact
-        long blueContact = createContact(mBlue, true);
-        long blueName = createName(mBlue, blueContact, GENERIC_NAME);
-        long bluePhone = createPhone(mBlue, blueContact, PHONE_BLUE);
+        long blueContact = mBlue.createContact(true);
+        long blueName = mBlue.createName(blueContact, GENERIC_NAME);
+        long bluePhone = mBlue.createPhone(blueContact, PHONE_BLUE);
 
         // Set the super-primary phone number to Red
-        setSuperPrimaryPhone(mRed, redPhone);
+        mRed.setSuperPrimaryPhone(redPhone);
 
         // Make sure both aggregates were joined
         long singleAgg;
         {
-            long redAgg = getAggregateForContact(mRed, redContact);
-            long blueAgg = getAggregateForContact(mBlue, blueContact);
+            long redAgg = mRed.getAggregateForContact(redContact);
+            long blueAgg = mBlue.getAggregateForContact(blueContact);
             assertTrue("Two contacts with identical name not aggregated correctly",
                     (redAgg == blueAgg));
             singleAgg = redAgg;
@@ -195,27 +189,27 @@
         // see any summary data, since it's own data is protected and it's not
         // the super-primary. Green shouldn't know this aggregate exists.
         assertTrue("Participant with restriction exception reading incorrect summary",
-                (getPrimaryPhoneId(mGrey, singleAgg) == redPhone));
+                (mGrey.getPrimaryPhoneId(singleAgg) == redPhone));
         assertTrue("Participant with super-primary restricted data reading incorrect summary",
-                (getPrimaryPhoneId(mRed, singleAgg) == redPhone));
+                (mRed.getPrimaryPhoneId(singleAgg) == redPhone));
         assertTrue("Participant with non-super-primary restricted data reading incorrect summary",
-                (getPrimaryPhoneId(mBlue, singleAgg) == 0));
+                (mBlue.getPrimaryPhoneId(singleAgg) == 0));
         assertTrue("Non-participant able to discover aggregate existance",
-                (getPrimaryPhoneId(mGreen, singleAgg) == 0));
+                (mGreen.getPrimaryPhoneId(singleAgg) == 0));
 
         // Add an unprotected Grey contact into the mix
-        long greyContact = createContact(mGrey, false);
-        long greyName = createName(mGrey, greyContact, GENERIC_NAME);
-        long greyPhone = createPhone(mGrey, greyContact, PHONE_GREY);
+        long greyContact = mGrey.createContact(false);
+        long greyName = mGrey.createName(greyContact, GENERIC_NAME);
+        long greyPhone = mGrey.createPhone(greyContact, PHONE_GREY);
 
         // Set the super-primary phone number to Blue
-        setSuperPrimaryPhone(mBlue, bluePhone);
+        mBlue.setSuperPrimaryPhone(bluePhone);
 
         // Make sure all three aggregates were joined
         {
-            long redAgg = getAggregateForContact(mRed, redContact);
-            long blueAgg = getAggregateForContact(mBlue, blueContact);
-            long greyAgg = getAggregateForContact(mGrey, greyContact);
+            long redAgg = mRed.getAggregateForContact(redContact);
+            long blueAgg = mBlue.getAggregateForContact(blueContact);
+            long greyAgg = mGrey.getAggregateForContact(greyContact);
             assertTrue("Three contacts with identical name not aggregated correctly",
                     (redAgg == blueAgg) && (blueAgg == greyAgg));
             singleAgg = redAgg;
@@ -226,13 +220,13 @@
         // Red doesn't see its own phone number because it's not super-primary,
         // and is protected. Again, green shouldn't know this exists.
         assertTrue("Participant with restriction exception reading incorrect summary",
-                (getPrimaryPhoneId(mGrey, singleAgg) == bluePhone));
+                (mGrey.getPrimaryPhoneId(singleAgg) == bluePhone));
         assertTrue("Participant with non-super-primary restricted data reading incorrect summary",
-                (getPrimaryPhoneId(mRed, singleAgg) == greyPhone));
+                (mRed.getPrimaryPhoneId(singleAgg) == greyPhone));
         assertTrue("Participant with super-primary restricted data reading incorrect summary",
-                (getPrimaryPhoneId(mBlue, singleAgg) == bluePhone));
+                (mBlue.getPrimaryPhoneId(singleAgg) == bluePhone));
         assertTrue("Non-participant couldn't find unrestricted primary through summary",
-                (getPrimaryPhoneId(mGreen, singleAgg) == greyPhone));
+                (mGreen.getPrimaryPhoneId(singleAgg) == greyPhone));
 
     }
 
@@ -247,12 +241,12 @@
         mGrey.provider.wipeData();
 
         // Green grants exception to itself
-        updateException(mGreen, PACKAGE_GREEN, PACKAGE_GREEN, true);
+        mGreen.updateException(PACKAGE_GREEN, PACKAGE_GREEN, true);
 
         // Green creates a protected contact
-        long greenContact = createContact(mGreen, true);
-        long greenData = createPhone(mGreen, greenContact, PHONE_GREEN);
-        long greenAgg = getAggregateForContact(mGreen, greenContact);
+        long greenContact = mGreen.createContact(true);
+        long greenData = mGreen.createPhone(greenContact, PHONE_GREEN);
+        long greenAgg = mGreen.getAggregateForContact(greenContact);
 
         // AGGREGATES
         cursor = mRed.resolver
@@ -341,119 +335,12 @@
 
     }
 
-    private long createContact(ContactsActor actor, boolean isRestricted) {
-        final ContentValues values = new ContentValues();
-        values.put(Contacts.PACKAGE, actor.packageName);
-        if (isRestricted) {
-            values.put(Contacts.IS_RESTRICTED, 1);
-        }
-
-        Uri contactUri = actor.resolver.insert(Contacts.CONTENT_URI, values);
-        return ContentUris.parseId(contactUri);
-    }
-
-    private long createName(ContactsActor actor, long contactId, String name) {
-        final ContentValues values = new ContentValues();
-        values.put(Data.CONTACT_ID, contactId);
-        values.put(Data.IS_PRIMARY, 1);
-        values.put(Data.IS_SUPER_PRIMARY, 1);
-        values.put(Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE);
-        values.put(CommonDataKinds.StructuredName.FAMILY_NAME, name);
-        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
-                contactId), Contacts.Data.CONTENT_DIRECTORY);
-        Uri dataUri = actor.resolver.insert(insertUri, values);
-        return ContentUris.parseId(dataUri);
-    }
-
-    private long createPhone(ContactsActor actor, long contactId, String phoneNumber) {
-        final ContentValues values = new ContentValues();
-        values.put(Data.CONTACT_ID, contactId);
-        values.put(Data.IS_PRIMARY, 1);
-        values.put(Data.IS_SUPER_PRIMARY, 1);
-        values.put(Data.MIMETYPE, Phones.CONTENT_ITEM_TYPE);
-        values.put(ContactsContract.CommonDataKinds.Phone.NUMBER, phoneNumber);
-        Uri insertUri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI,
-                contactId), Contacts.Data.CONTENT_DIRECTORY);
-        Uri dataUri = actor.resolver.insert(insertUri, values);
-        return ContentUris.parseId(dataUri);
-    }
-
-    private void updateException(ContactsActor actor, String packageProvider,
-            String packageClient, boolean allowAccess) {
-        final ContentValues values = new ContentValues();
-        values.put(RestrictionExceptions.PACKAGE_PROVIDER, packageProvider);
-        values.put(RestrictionExceptions.PACKAGE_CLIENT, packageClient);
-        values.put(RestrictionExceptions.ALLOW_ACCESS, allowAccess ? 1 : 0);
-        actor.resolver.update(RestrictionExceptions.CONTENT_URI, values, null, null);
-    }
-
-    private long getAggregateForContact(ContactsActor actor, long contactId) {
-        Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
-        final Cursor cursor = actor.resolver.query(contactUri, Projections.PROJ_CONTACTS, null,
-                null, null);
-        assertTrue("Contact didn't have an aggregate", cursor.moveToFirst());
-        final long aggId = cursor.getLong(Projections.COL_CONTACTS_AGGREGATE);
-        cursor.close();
-        return aggId;
-    }
-
-    private int getDataCountForAggregate(ContactsActor actor, long aggId) {
-        Uri contactUri = Uri.withAppendedPath(ContentUris.withAppendedId(Aggregates.CONTENT_URI,
-                aggId), Aggregates.Data.CONTENT_DIRECTORY);
-        final Cursor cursor = actor.resolver.query(contactUri, Projections.PROJ_ID, null, null,
-                null);
-        final int count = cursor.getCount();
-        cursor.close();
-        return count;
-    }
-
-    private void setSuperPrimaryPhone(ContactsActor actor, long dataId) {
-        final ContentValues values = new ContentValues();
-        values.put(Data.IS_PRIMARY, 1);
-        values.put(Data.IS_SUPER_PRIMARY, 1);
-        Uri updateUri = ContentUris.withAppendedId(Data.CONTENT_URI, dataId);
-        actor.resolver.update(updateUri, values, null, null);
-    }
-
-    private long getPrimaryPhoneId(ContactsActor actor, long aggId) {
-        Uri aggUri = ContentUris.withAppendedId(Aggregates.CONTENT_URI, aggId);
-        final Cursor cursor = actor.resolver.query(aggUri, Projections.PROJ_AGGREGATES, null,
-                null, null);
-        long primaryPhoneId = -1;
-        if (cursor.moveToFirst()) {
-            primaryPhoneId = cursor.getLong(Projections.COL_AGGREGATES_PRIMARY_PHONE_ID);
-        }
-        if (LOGD) {
-            Log.d(TAG, "for actor=" + actor.packageName + ", aggId=" + aggId
-                    + ", found getCount()=" + cursor.getCount() + ", primaryPhoneId="
-                    + primaryPhoneId);
-        }
-        cursor.close();
-        return primaryPhoneId;
-    }
-
-    /**
-     * Various internal database projections.
-     */
     private interface Projections {
         static final String[] PROJ_ID = new String[] {
                 BaseColumns._ID,
         };
 
         static final int COL_ID = 0;
-
-        static final String[] PROJ_CONTACTS = new String[] {
-                Contacts.AGGREGATE_ID
-        };
-
-        static final int COL_CONTACTS_AGGREGATE = 0;
-
-        static final String[] PROJ_AGGREGATES = new String[] {
-                Aggregates.PRIMARY_PHONE_ID
-        };
-
-        static final int COL_AGGREGATES_PRIMARY_PHONE_ID = 0;
-
     }
 
 }