Merge commit 'goog/eclair-dev' into merge3

Merged the new contacts content provider into goog/master. The old and
new content providers now live side by side under separate authorities.

Conflicts:
	Android.mk
	AndroidManifest.xml
	res/values/strings.xml
diff --git a/Android.mk b/Android.mk
index b13e757..e963f99 100644
--- a/Android.mk
+++ b/Android.mk
@@ -3,7 +3,8 @@
 
 LOCAL_MODULE_TAGS := user
 
-LOCAL_SRC_FILES := $(call all-subdir-java-files)
+# Only compile source java files in this apk.
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
 
 LOCAL_JAVA_LIBRARIES := ext
 
@@ -11,3 +12,6 @@
 LOCAL_CERTIFICATE := shared
 
 include $(BUILD_PACKAGE)
+
+# Use the following include to make our test apk.
+include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 0194108..4491dd6 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -13,11 +13,28 @@
     <uses-permission android:name="android.permission.SUBSCRIBED_FEEDS_WRITE" />
 
     <application android:process="android.process.acore"
-                 android:label="@string/app_label"
-                 android:icon="@drawable/app_icon">
-        <provider android:name="ContactsProvider" android:authorities="contacts;call_log"
-                android:syncable="false" android:multiprocess="false"
-                android:readPermission="android.permission.READ_CONTACTS"
-                android:writePermission="android.permission.WRITE_CONTACTS" />
+        android:label="@string/app_label"
+        android:icon="@drawable/app_icon">
+
+        <provider android:name="ContactsProvider" 
+            android:authorities="contacts;call_log"
+            android:syncable="false" android:multiprocess="false"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS" />
+
+        <provider android:name="ContactsProvider2"
+            android:authorities="com.android.contacts"
+            android:syncable="false"
+            android:multiprocess="false"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS" />
+
+        <!-- TODO: create permissions for social data -->
+        <provider android:name="SocialProvider"
+            android:authorities="com.android.social"
+            android:syncable="false"
+            android:multiprocess="false"
+            android:readPermission="android.permission.READ_CONTACTS"
+            android:writePermission="android.permission.WRITE_CONTACTS" />
     </application>
 </manifest>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 45637dd..ac119ed 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1,5 +1,5 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Copyright (C) 2008 The Android Open Source Project
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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.
@@ -14,7 +14,8 @@
      limitations under the License.
 -->
 
-<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+<resources xmlns:android="http://schemas.android.com/apk/res/android"
+           xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
 
     <!-- This is the label for the application that stores contacts data -->
     <string name="app_label">Contacts Storage</string>
diff --git a/src/com/android/providers/contacts/ContactAggregationScheduler.java b/src/com/android/providers/contacts/ContactAggregationScheduler.java
new file mode 100644
index 0000000..59bb8cd
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactAggregationScheduler.java
@@ -0,0 +1,176 @@
+/*
+ * 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.contacts;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Process;
+
+/**
+ * A scheduler for asynchronous aggregation of contacts. Aggregation will start after
+ * a short delay after it is scheduled, unless it is scheduled again, in which case the
+ * aggregation pass is delayed.  There is an upper boundary on how long aggregation can
+ * be delayed.
+ */
+public class ContactAggregationScheduler {
+
+    public interface Aggregator {
+
+        /**
+         * Performs an aggregation run.
+         */
+        void run();
+
+        /**
+         * Interrupts aggregation.
+         */
+        void interrupt();
+    }
+
+    // Message ID used for communication with the aggregator
+    private static final int START_AGGREGATION_MESSAGE_ID = 1;
+
+    // Aggregation is delayed by this many milliseconds to allow changes to accumulate
+    public static final int AGGREGATION_DELAY = 500;
+
+    // Maximum delay of aggregation from the initial aggregation request
+    public static final int MAX_AGGREGATION_DELAY = 5000;
+
+    public static final int STATUS_STAND_BY = 0;
+    public static final int STATUS_SCHEDULED = 1;
+    public static final int STATUS_RUNNING = 2;
+
+    private Aggregator mAggregator;
+
+    // Aggregation status
+    private int mStatus = STATUS_STAND_BY;
+
+    // If true, we need to automatically reschedule aggregation after the current pass is done
+    private boolean mRescheduleWhenComplete;
+
+    // The time when aggregation was request for the first time. Reset when aggregation is completed
+    private long mInitialRequestTimestamp;
+
+    private HandlerThread mHandlerThread;
+    private Handler mMessageHandler;
+
+    public void setAggregator(Aggregator aggregator) {
+        mAggregator = aggregator;
+    }
+
+    public void start() {
+        mHandlerThread = new HandlerThread("ContactAggregator", Process.THREAD_PRIORITY_BACKGROUND);
+        mHandlerThread.start();
+        mMessageHandler = new Handler(mHandlerThread.getLooper()) {
+
+            @Override
+            public void handleMessage(Message msg) {
+                switch (msg.what) {
+                    case START_AGGREGATION_MESSAGE_ID:
+                        run();
+                        break;
+
+                    default:
+                        throw new IllegalStateException("Unhandled message: " + msg.what);
+                }
+            }
+        };
+    }
+
+    public void stop() {
+        mAggregator.interrupt();
+        Looper looper = mHandlerThread.getLooper();
+        if (looper != null) {
+            looper.quit();
+        }
+    }
+
+    /**
+     * Schedules an aggregation pass after a short delay.
+     */
+    public synchronized void schedule() {
+
+        switch (mStatus) {
+            case STATUS_STAND_BY: {
+
+                mInitialRequestTimestamp = currentTime();
+                mStatus = STATUS_SCHEDULED;
+                runDelayed();
+                break;
+            }
+
+            case STATUS_SCHEDULED: {
+
+                // If it has been less than MAX_AGGREGATION_DELAY millis since the initial request,
+                // reschedule the request.
+                if (currentTime() - mInitialRequestTimestamp < MAX_AGGREGATION_DELAY) {
+                    runDelayed();
+                }
+                break;
+            }
+
+            case STATUS_RUNNING: {
+
+                // If it has been less than MAX_AGGREGATION_DELAY millis since the initial request,
+                // interrupt the current pass and reschedule the request.
+                if (currentTime() - mInitialRequestTimestamp < MAX_AGGREGATION_DELAY) {
+                    mAggregator.interrupt();
+                }
+
+                mRescheduleWhenComplete = true;
+                break;
+            }
+        }
+    }
+
+    /**
+     * Called just before an aggregation pass begins.
+     */
+    public void run() {
+        synchronized (this) {
+            mStatus = STATUS_RUNNING;
+            mRescheduleWhenComplete = false;
+        }
+        try {
+            mAggregator.run();
+        } finally {
+            synchronized (this) {
+                mStatus = STATUS_STAND_BY;
+                mInitialRequestTimestamp = 0;
+                if (mRescheduleWhenComplete) {
+                    mRescheduleWhenComplete = false;
+                    schedule();
+                }
+            }
+        }
+    }
+
+    /* package */ void runDelayed() {
+
+        // If aggregation has already been requested, cancel the previous request
+        mMessageHandler.removeMessages(START_AGGREGATION_MESSAGE_ID);
+
+        // Schedule aggregation for AGGREGATION_DELAY milliseconds from now
+        mMessageHandler.sendEmptyMessageDelayed(
+                START_AGGREGATION_MESSAGE_ID, AGGREGATION_DELAY);
+    }
+
+    /* package */ long currentTime() {
+        return System.currentTimeMillis();
+    }
+}
diff --git a/src/com/android/providers/contacts/ContactAggregator.java b/src/com/android/providers/contacts/ContactAggregator.java
new file mode 100644
index 0000000..01e247d
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactAggregator.java
@@ -0,0 +1,1147 @@
+/*
+ * 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.contacts;
+
+import com.android.providers.contacts.ContactMatcher.MatchScore;
+import com.android.providers.contacts.OpenHelper.AggregatesColumns;
+import com.android.providers.contacts.OpenHelper.AggregationExceptionColumns;
+import com.android.providers.contacts.OpenHelper.Clauses;
+import com.android.providers.contacts.OpenHelper.ContactOptionsColumns;
+import com.android.providers.contacts.OpenHelper.ContactsColumns;
+import com.android.providers.contacts.OpenHelper.MimetypesColumns;
+import com.android.providers.contacts.OpenHelper.NameLookupColumns;
+import com.android.providers.contacts.OpenHelper.NameLookupType;
+import com.android.providers.contacts.OpenHelper.Tables;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+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;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.text.util.Rfc822Tokenizer;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+
+
+/**
+ * ContactAggregator deals with aggregating contact information coming from different sources.
+ * Two John Doe contacts from two disjoint sources are presumed to be the same
+ * person unless the user declares otherwise.
+ * <p>
+ * ContactAggregator runs on a separate thread.
+ */
+public class ContactAggregator implements ContactAggregationScheduler.Aggregator {
+
+    private static final String TAG = "ContactAggregator";
+
+    // Data mime types used in the contact matching algorithm
+    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[] {
+            MimetypesColumns.MIMETYPE,
+            Data.DATA1,
+            Data.DATA2
+    };
+
+    private static final int COL_MIMETYPE = 0;
+    private static final int COL_DATA1 = 1;
+    private static final int COL_DATA2 = 2;
+
+    private static final String[] DATA_JOIN_MIMETYPE_AND_CONTACT_COLUMNS = new String[] {
+            Data.DATA1, Data.DATA2, Contacts.AGGREGATE_ID
+    };
+
+    private static final int COL_DATA_CONTACT_DATA1 = 0;
+    private static final int COL_DATA_CONTACT_DATA2 = 1;
+    private static final int COL_DATA_CONTACT_AGGREGATE_ID = 2;
+
+    private static final String[] NAME_LOOKUP_COLUMNS = new String[] {
+            Contacts.AGGREGATE_ID, NameLookupColumns.NORMALIZED_NAME, NameLookupColumns.NAME_TYPE
+    };
+
+    private static final int COL_NAME_LOOKUP_AGGREGATE_ID = 0;
+    private static final int COL_NORMALIZED_NAME = 1;
+    private static final int COL_NAME_TYPE = 2;
+
+    private static final String[] AGGREGATE_EXCEPTION_JOIN_CONTACT_TWICE_COLUMNS = new String[]{
+            AggregationExceptions.TYPE,
+            AggregationExceptionColumns.CONTACT_ID1,
+            "contacts1." + Contacts.AGGREGATE_ID,
+            "contacts2." + Contacts.AGGREGATE_ID
+    };
+
+    private static final int COL_TYPE = 0;
+    private static final int COL_CONTACT_ID1 = 1;
+    private static final int COL_AGGREGATE_ID1 = 2;
+    private static final int COL_AGGREGATE_ID2 = 3;
+
+    private static final String[] CONTACT_ID_COLUMN = new String[] { Contacts._ID };
+    private static final String[] CONTACTS_JOIN_CONTACT_OPTIONS_COLUMNS = new String[] {
+            ContactOptionsColumns.CUSTOM_RINGTONE,
+            ContactOptionsColumns.SEND_TO_VOICEMAIL,
+    };
+
+    private static final int COL_CUSTOM_RINGTONE = 0;
+    private static final int COL_SEND_TO_VOICEMAIL = 1;
+
+    private static final String[] AGGREGATE_ID_COLUMNS = new String[]{ Contacts.AGGREGATE_ID };
+    private static final int COL_AGGREGATE_ID = 0;
+
+    private static final int MODE_INSERT_LOOKUP_DATA = 0;
+    private static final int MODE_AGGREGATION = 1;
+    private static final int MODE_SUGGESTIONS = 2;
+
+    private final OpenHelper mOpenHelper;
+    private final ContactAggregationScheduler mScheduler;
+
+    // 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
+     * and then executes the search in bulk.
+     */
+    private static class NameMatchCandidate {
+        String mName;
+        int mLookupType;
+
+        public NameMatchCandidate(String name, int nameLookupType) {
+            mName = name;
+            mLookupType = nameLookupType;
+        }
+    }
+
+    /**
+     * A list of {@link NameMatchCandidate} that keeps its elements even when the list is
+     * truncated. This is done for optimization purposes to avoid excessive object allocation.
+     */
+    private static class MatchCandidateList {
+        private final ArrayList<NameMatchCandidate> mList = new ArrayList<NameMatchCandidate>();
+        private int mCount;
+
+        /**
+         * Adds a {@link NameMatchCandidate} element or updates the next one if it already exists.
+         */
+        public void add(String name, int nameLookupType) {
+            if (mCount >= mList.size()) {
+                mList.add(new NameMatchCandidate(name, nameLookupType));
+            } else {
+                NameMatchCandidate candidate = mList.get(mCount);
+                candidate.mName = name;
+                candidate.mLookupType = nameLookupType;
+            }
+            mCount++;
+        }
+
+        public void clear() {
+            mCount = 0;
+        }
+    }
+
+    /**
+     * Constructor.  Starts a contact aggregation thread.  Call {@link #quit} to kill the
+     * aggregation thread.  Call {@link #schedule} to kick off the aggregation process after
+     * a delay of {@link #AGGREGATION_DELAY} milliseconds.
+     */
+    public ContactAggregator(Context context, OpenHelper openHelper,
+            ContactAggregationScheduler scheduler) {
+        mOpenHelper = openHelper;
+        mScheduler = scheduler;
+        mScheduler.setAggregator(this);
+        mScheduler.start();
+
+        // Perform an aggregation pass in the beginning, which will most of the time
+        // do nothing.  It will only be useful if the content provider has been killed
+        // before completing aggregation.
+        mScheduler.schedule();
+    }
+
+    /**
+     * Schedules aggregation pass after a short delay.  This method should be called every time
+     * the {@link Contacts#AGGREGATE_ID} field is reset on any record.
+     */
+    public void schedule() {
+        mScheduler.schedule();
+    }
+
+    /**
+     * Kills the contact aggregation thread.
+     */
+    public void quit() {
+        mScheduler.stop();
+    }
+
+    /**
+     * Invoked by the scheduler to cancel aggregation.
+     */
+    public void interrupt() {
+        mCancel = true;
+    }
+
+    /**
+     * Find all contacts that require aggregation and pass them through aggregation one by one.
+     * Do not call directly.  It is invoked by the scheduler.
+     */
+    public void run() {
+        mCancel = false;
+        Log.i(TAG, "Contact aggregation");
+
+        MatchCandidateList candidates = new MatchCandidateList();
+        ContactMatcher matcher = new ContactMatcher();
+        ContentValues values = new ContentValues();
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        final Cursor c = db.query(Tables.CONTACTS, new String[]{Contacts._ID},
+                Contacts.AGGREGATE_ID + " IS NULL", null, null, null, null);
+
+        int totalCount = c.getCount();
+        int count = 0;
+        try {
+            if (c.moveToFirst()) {
+                db.beginTransaction();
+                try {
+                    do {
+                        if (mCancel) {
+                            break;
+                        }
+                        aggregateContact(db, c.getInt(0), candidates, matcher, values);
+                        count++;
+                        db.yieldIfContendedSafely();
+                    } while (c.moveToNext());
+
+                    db.setTransactionSuccessful();
+                } finally {
+                    db.endTransaction();
+                }
+            }
+        } finally {
+            c.close();
+
+            // Unless the aggregation pass was not interrupted, reset the last request timestamp
+            if (count == totalCount) {
+                Log.i(TAG, "Contact aggregation complete: " + totalCount);
+            } else {
+                Log.i(TAG, "Contact aggregation interrupted: " + count + "/" + totalCount);
+            }
+        }
+    }
+
+    /**
+     * Synchronously aggregate the specified contact.
+     */
+    public void aggregateContact(long contactId) {
+        MatchCandidateList candidates = new MatchCandidateList();
+        ContactMatcher matcher = new ContactMatcher();
+        ContentValues values = new ContentValues();
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            aggregateContact(db, contactId, candidates, matcher, values);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    /**
+     * Marks the specified contact for (re)aggregation.
+     *
+     * @param contactId contact ID that needs to be (re)aggregated
+     */
+    public void markContactForAggregation(long contactId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        long aggregateId = mOpenHelper.getAggregateId(contactId);
+        if (aggregateId != 0) {
+
+            // Clear out the aggregate ID field on the contact
+            db.execSQL("UPDATE " + Tables.CONTACTS + " SET " + Contacts.AGGREGATE_ID
+                    + " = NULL WHERE " + Contacts._ID + "=" + contactId + ";");
+
+            // Clear out data used for aggregation - we will recreate it during aggregation
+            db.execSQL("DELETE FROM " + Tables.NAME_LOOKUP + " WHERE "
+                    + NameLookupColumns.CONTACT_ID + "=" + contactId);
+
+            // Delete the aggregate itself if it no longer has constituent contacts
+            db.execSQL("DELETE FROM " + Tables.AGGREGATES + " WHERE " + Aggregates._ID + "="
+                    + aggregateId + " AND " + Aggregates._ID + " NOT IN (SELECT "
+                    + Contacts.AGGREGATE_ID + " FROM " + Tables.CONTACTS + ");");
+        }
+    }
+
+    public void updateAggregateData(long aggregateId) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        final ContentValues values = new ContentValues();
+        updateAggregateData(db, aggregateId, values);
+    }
+
+    /**
+     * Given a specific contact, finds all matching aggregates and chooses the aggregate
+     * with the highest match score.  If no such aggregate is found, creates a new aggregate.
+     */
+    /* package */ synchronized void aggregateContact(SQLiteDatabase db, long contactId,
+            MatchCandidateList candidates, ContactMatcher matcher, ContentValues values) {
+        candidates.clear();
+        matcher.clear();
+
+        long aggregateId = pickBestMatchBasedOnExceptions(db, contactId, matcher);
+        if (aggregateId == -1) {
+            aggregateId = pickBestMatchBasedOnData(db, contactId, candidates, matcher);
+        }
+
+        boolean newAgg = false;
+
+        if (aggregateId == -1) {
+            newAgg = true;
+            ContentValues aggregateValues = new ContentValues();
+            aggregateValues.put(Aggregates.DISPLAY_NAME, "");
+            aggregateId = db.insert(Tables.AGGREGATES, Aggregates.DISPLAY_NAME, aggregateValues);
+        }
+
+        updateContactAggregationData(db, contactId, candidates, values);
+        mOpenHelper.setAggregateId(contactId, aggregateId);
+
+        updateAggregateData(db, aggregateId, values);
+        updatePrimaries(db, aggregateId, contactId, newAgg);
+        mOpenHelper.updateAggregateVisible(aggregateId);
+
+    }
+
+    /**
+     * Computes match scores based on exceptions entered by the user: always match and never match.
+     * Returns the aggregate with the always match exception if any.
+     */
+    private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long contactId,
+            ContactMatcher matcher) {
+        final Cursor c = db.query(Tables.AGGREGATION_EXCEPTIONS_JOIN_CONTACTS_TWICE,
+                AGGREGATE_EXCEPTION_JOIN_CONTACT_TWICE_COLUMNS,
+                AggregationExceptionColumns.CONTACT_ID1 + "=" + contactId
+                        + " OR " + AggregationExceptionColumns.CONTACT_ID2 + "=" + contactId,
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                int type = c.getInt(COL_TYPE);
+                long contactId1 = c.getLong(COL_CONTACT_ID1);
+                long aggregateId = -1;
+                if (contactId == contactId1) {
+                    if (!c.isNull(COL_AGGREGATE_ID2)) {
+                        aggregateId = c.getLong(COL_AGGREGATE_ID2);
+                    }
+                } else {
+                    if (!c.isNull(COL_AGGREGATE_ID1)) {
+                        aggregateId = c.getLong(COL_AGGREGATE_ID1);
+                    }
+                }
+                if (aggregateId != -1) {
+                    if (type == AggregationExceptions.TYPE_KEEP_IN) {
+                        return aggregateId;
+                    } else {
+                        matcher.keepOut(aggregateId);
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        return -1;
+    }
+
+    /**
+     * Picks the best matching aggregate based on matches between data elements.  It considers
+     * name match to be primary and phone, email etc matches to be secondary.  A good primary
+     * match triggers aggregation, while a good secondary match only triggers aggregation in
+     * the absence of a strong primary mismatch.
+     * <p>
+     * Consider these examples:
+     * <p>
+     * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should
+     * be aggregated (same number, similar names).
+     * <p>
+     * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should
+     * not be aggregated (same number, different names).
+     */
+    private long pickBestMatchBasedOnData(SQLiteDatabase db, long contactId,
+            MatchCandidateList candidates, ContactMatcher matcher) {
+
+        updateMatchScoresBasedOnDataMatches(db, contactId, MODE_AGGREGATION, candidates, matcher);
+
+        // See if we have already found a good match based on name matches alone
+        long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY);
+        if (bestMatch == -1) {
+            // We haven't found a good match on name, see if we have any matches on phone, email etc
+            bestMatch = pickBestMatchBasedOnSecondaryData(db, candidates, matcher);
+        }
+
+        return bestMatch;
+    }
+
+    /**
+     * Picks the best matching aggregate based on secondary data matches.  The method loads
+     * structured names for all candidate aggregates and recomputes match scores using approximate
+     * matching.
+     */
+    private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db,
+            MatchCandidateList candidates, ContactMatcher matcher) {
+        List<Long> secondaryAggregateIds = matcher.prepareSecondaryMatchCandidates(
+                ContactMatcher.SCORE_THRESHOLD_PRIMARY);
+        if (secondaryAggregateIds == null) {
+            return -1;
+        }
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(Contacts.AGGREGATE_ID).append(" IN (");
+        for (int i = 0; i < secondaryAggregateIds.size(); i++) {
+            if (i != 0) {
+                selection.append(',');
+            }
+            selection.append(secondaryAggregateIds.get(i));
+        }
+        selection.append(") AND ")
+                .append(MimetypesColumns.MIMETYPE)
+                .append("='")
+                .append(StructuredName.CONTENT_ITEM_TYPE)
+                .append("'");
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS,
+                DATA_JOIN_MIMETYPE_AND_CONTACT_COLUMNS,
+                selection.toString(), null, null, null, null);
+
+        MatchCandidateList nameCandidates = new MatchCandidateList();
+        try {
+            while (c.moveToNext()) {
+                String givenName = c.getString(COL_DATA_CONTACT_DATA1);
+                String familyName = c.getString(COL_DATA_CONTACT_DATA2);
+                long aggregateId = c.getLong(COL_DATA_CONTACT_AGGREGATE_ID);
+
+                nameCandidates.clear();
+                addMatchCandidatesStructuredName(givenName, familyName, MODE_INSERT_LOOKUP_DATA,
+                        nameCandidates);
+
+                // Note the N^2 complexity of the following fragment. This is not a huge concern
+                // since the number of candidates is very small and in general secondary hits
+                // in the absence of primary hits are rare.
+                for (int i = 0; i < candidates.mCount; i++) {
+                    NameMatchCandidate candidate = candidates.mList.get(i);
+
+                    // We only want to compare structured names to structured names
+                    // at this stage, we need to ignore all other sources of name lookup data.
+                    if (NameLookupType.isBasedOnStructuredName(candidate.mLookupType)) {
+                        for (int j = 0; j < nameCandidates.mCount; j++) {
+                            NameMatchCandidate nameCandidate = nameCandidates.mList.get(j);
+                            matcher.matchName(aggregateId,
+                                    nameCandidate.mLookupType, nameCandidate.mName,
+                                    candidate.mLookupType, candidate.mName, true);
+                        }
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY);
+    }
+
+    /**
+     * Computes scores for aggregates that have matching data rows.
+     */
+    private void updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long contactId,
+            int mode, MatchCandidateList candidates, ContactMatcher matcher) {
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS,
+                DATA_JOIN_MIMETYPE_COLUMNS,
+                DatabaseUtils.concatenateWhere(Data.CONTACT_ID + "=" + contactId,
+                        MIMETYPE_SELECTION_IN_CLAUSE),
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                String mimeType = c.getString(COL_MIMETYPE);
+                String data1 = c.getString(COL_DATA1);
+                String data2 = c.getString(COL_DATA2);
+                if (mimeType.equals(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesStructuredName(data1, data2, mode, candidates);
+                } else if (mimeType.equals(CommonDataKinds.Email.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesEmail(data2, mode, candidates);
+                    lookupEmailMatches(db, data2, matcher);
+                } else if (mimeType.equals(CommonDataKinds.Phone.CONTENT_ITEM_TYPE)) {
+                    lookupPhoneMatches(db, data2, matcher);
+                } else if (mimeType.equals(CommonDataKinds.Nickname.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesNickname(data2, mode, candidates);
+                    lookupNicknameMatches(db, data2, matcher);
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        lookupNameMatches(db, candidates, matcher);
+
+        if (mode == MODE_SUGGESTIONS) {
+            lookupApproximateNameMatches(db, candidates, matcher);
+        }
+    }
+
+    /**
+     * Looks for matches based on the full name (first + last).
+     */
+    private void addMatchCandidatesStructuredName(String givenName, String familyName, int mode,
+            MatchCandidateList candidates) {
+        if (TextUtils.isEmpty(givenName)) {
+
+            // If neither the first nor last name are specified, we won't
+            // aggregate
+            if (TextUtils.isEmpty(familyName)) {
+                return;
+            }
+
+            addMatchCandidatesFamilyNameOnly(familyName, candidates);
+        } else if (TextUtils.isEmpty(familyName)) {
+            addMatchCandidatesGivenNameOnly(givenName, candidates);
+        } else {
+            addMatchCandidatesFullName(givenName, familyName, mode, candidates);
+        }
+    }
+
+    private void addMatchCandidatesGivenNameOnly(String givenName,
+            MatchCandidateList candidates) {
+        String givenNameN = NameNormalizer.normalize(givenName);
+        candidates.add(givenNameN, NameLookupType.GIVEN_NAME_ONLY);
+
+        String[] clusters = mOpenHelper.getCommonNicknameClusters(givenNameN);
+        if (clusters != null) {
+            for (int i = 0; i < clusters.length; i++) {
+                candidates.add(clusters[i], NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME);
+            }
+        }
+    }
+
+    private void addMatchCandidatesFamilyNameOnly(String familyName,
+            MatchCandidateList candidates) {
+        String familyNameN = NameNormalizer.normalize(familyName);
+        candidates.add(familyNameN, NameLookupType.FAMILY_NAME_ONLY);
+
+        // Take care of first and last names swapped
+        String[] clusters = mOpenHelper.getCommonNicknameClusters(familyNameN);
+        if (clusters != null) {
+            for (int i = 0; i < clusters.length; i++) {
+                candidates.add(clusters[i], NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME);
+            }
+        }
+    }
+
+    private void addMatchCandidatesFullName(String givenName, String familyName, int mode,
+            MatchCandidateList candidates) {
+        final String givenNameN = NameNormalizer.normalize(givenName);
+        final String[] givenNameNicknames = mOpenHelper.getCommonNicknameClusters(givenNameN);
+        final String familyNameN = NameNormalizer.normalize(familyName);
+        final String[] familyNameNicknames = mOpenHelper.getCommonNicknameClusters(familyNameN);
+        candidates.add(givenNameN + "." + familyNameN, NameLookupType.FULL_NAME);
+        if (givenNameNicknames != null) {
+            for (int i = 0; i < givenNameNicknames.length; i++) {
+                candidates.add(givenNameNicknames[i] + "." + familyNameN,
+                        NameLookupType.FULL_NAME_WITH_NICKNAME);
+            }
+        }
+        candidates.add(familyNameN + "." + givenNameN, NameLookupType.FULL_NAME_REVERSE);
+        if (familyNameNicknames != null) {
+            for (int i = 0; i < familyNameNicknames.length; i++) {
+                candidates.add(familyNameNicknames[i] + "." + givenNameN,
+                        NameLookupType.FULL_NAME_WITH_NICKNAME_REVERSE);
+            }
+        }
+        candidates.add(givenNameN + familyNameN, NameLookupType.FULL_NAME_CONCATENATED);
+        candidates.add(familyNameN + givenNameN, NameLookupType.FULL_NAME_REVERSE_CONCATENATED);
+
+        if (mode == MODE_AGGREGATION || mode == MODE_SUGGESTIONS) {
+            candidates.add(givenNameN, NameLookupType.GIVEN_NAME_ONLY);
+            if (givenNameNicknames != null) {
+                for (int i = 0; i < givenNameNicknames.length; i++) {
+                    candidates.add(givenNameNicknames[i],
+                            NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME);
+                }
+            }
+
+            candidates.add(familyNameN, NameLookupType.FAMILY_NAME_ONLY);
+            if (familyNameNicknames != null) {
+                for (int i = 0; i < familyNameNicknames.length; i++) {
+                    candidates.add(familyNameNicknames[i],
+                            NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME);
+                }
+            }
+        }
+    }
+
+    /**
+     * Extracts the user name portion from an email address and normalizes it so that it
+     * can be matched against names and nicknames.
+     */
+    private void addMatchCandidatesEmail(String email, int mode, MatchCandidateList candidates) {
+        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(email);
+        if (tokens.length == 0) {
+            return;
+        }
+
+        String address = tokens[0].getAddress();
+        int at = address.indexOf('@');
+        if (at != -1) {
+            address = address.substring(0, at);
+        }
+
+        candidates.add(NameNormalizer.normalize(address), NameLookupType.EMAIL_BASED_NICKNAME);
+    }
+
+
+    /**
+     * Normalizes the nickname and adds it to the list of candidates.
+     */
+    private void addMatchCandidatesNickname(String nickname, int mode,
+            MatchCandidateList candidates) {
+        candidates.add(NameNormalizer.normalize(nickname), NameLookupType.NICKNAME);
+    }
+
+    /**
+     * Given a list of {@link NameMatchCandidate}'s, finds all matches and computes their scores.
+     */
+    private void lookupNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
+            ContactMatcher matcher) {
+
+        if (candidates.mCount == 0) {
+            return;
+        }
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(NameLookupColumns.NORMALIZED_NAME);
+        selection.append(" IN (");
+        for (int i = 0; i < candidates.mCount; i++) {
+            DatabaseUtils.appendEscapedSQLString(selection, candidates.mList.get(i).mName);
+            selection.append(",");
+        }
+
+        // Yank the last comma
+        selection.setLength(selection.length() - 1);
+        selection.append(") AND ");
+        selection.append(Contacts.AGGREGATE_ID);
+        selection.append(" NOT NULL");
+
+        matchAllCandidates(db, selection.toString(), candidates, matcher, false);
+    }
+
+    /**
+     * Loads name lookup rows for approximate name matching and updates match scores based on that
+     * data.
+     */
+    private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
+            ContactMatcher matcher) {
+        HashSet<String> firstLetters = new HashSet<String>();
+        for (int i = 0; i < candidates.mCount; i++) {
+            final NameMatchCandidate candidate = candidates.mList.get(i);
+            if (candidate.mName.length() >= 2) {
+                String firstLetter = candidate.mName.substring(0, 2);
+                if (!firstLetters.contains(firstLetter)) {
+                    firstLetters.add(firstLetter);
+                    final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
+                            + firstLetter + "*') AND " + Contacts.AGGREGATE_ID + " NOT NULL";
+                    matchAllCandidates(db, selection, candidates, matcher, true);
+                }
+            }
+        }
+    }
+
+    /**
+     * Loads all candidate rows from the name lookup table and updates match scores based
+     * on that data.
+     */
+    private void matchAllCandidates(SQLiteDatabase db, String selection,
+            MatchCandidateList candidates, ContactMatcher matcher, boolean approximate) {
+        final Cursor c = db.query(Tables.NAME_LOOKUP_JOIN_CONTACTS, NAME_LOOKUP_COLUMNS,
+                selection, null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                Long aggregateId = c.getLong(COL_NAME_LOOKUP_AGGREGATE_ID);
+                String name = c.getString(COL_NORMALIZED_NAME);
+                int nameType = c.getInt(COL_NAME_TYPE);
+
+                // Determine which candidate produced this match
+                for (int i = 0; i < candidates.mCount; i++) {
+                    NameMatchCandidate candidate = candidates.mList.get(i);
+                    matcher.matchName(aggregateId, candidate.mLookupType, candidate.mName,
+                            nameType, name, approximate);
+                }
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    private void lookupPhoneMatches(SQLiteDatabase db, String phoneNumber, ContactMatcher matcher) {
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        OpenHelper.buildPhoneLookupQuery(qb, phoneNumber);
+        Cursor c = qb.query(db, AGGREGATE_ID_COLUMNS,
+                Contacts.AGGREGATE_ID + " NOT NULL", null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long aggregateId = c.getLong(COL_AGGREGATE_ID);
+                matcher.updateScoreWithPhoneNumberMatch(aggregateId);
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Finds exact email matches and updates their match scores.
+     */
+    private void lookupEmailMatches(SQLiteDatabase db, String address, ContactMatcher matcher) {
+        Cursor c = db.query(Tables.DATA_JOIN_MIMETYPE_CONTACTS, AGGREGATE_ID_COLUMNS,
+                Clauses.WHERE_EMAIL_MATCHES + " AND " + Contacts.AGGREGATE_ID + " NOT NULL",
+                new String[]{address}, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long aggregateId = c.getLong(COL_AGGREGATE_ID);
+                matcher.updateScoreWithEmailMatch(aggregateId);
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Finds exact nickname matches in the name lookup table and updates their match scores.
+     */
+    private void lookupNicknameMatches(SQLiteDatabase db, String nickname, ContactMatcher matcher) {
+        String normalized = NameNormalizer.normalize(nickname);
+        Cursor c = db.query(true, Tables.NAME_LOOKUP_JOIN_CONTACTS, AGGREGATE_ID_COLUMNS,
+                NameLookupColumns.NAME_TYPE + "=" + NameLookupType.NICKNAME + " AND "
+                        + NameLookupColumns.NORMALIZED_NAME + "='" + normalized + "' AND "
+                        + Contacts.AGGREGATE_ID + " NOT NULL",
+                null, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long aggregateId = c.getLong(COL_AGGREGATE_ID);
+                matcher.updateScoreWithNicknameMatch(aggregateId);
+            }
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Prepares the supplied contact for aggregation with other contacts by (re)computing
+     * match lookup keys.
+     */
+    private void updateContactAggregationData(SQLiteDatabase db, long contactId,
+            MatchCandidateList candidates, ContentValues values) {
+        candidates.clear();
+
+        final Cursor c = db.query(Tables.DATA_JOIN_MIMETYPES,
+                DATA_JOIN_MIMETYPE_COLUMNS,
+                DatabaseUtils.concatenateWhere(Data.CONTACT_ID + "=" + contactId,
+                        MIMETYPE_SELECTION_IN_CLAUSE),
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                String mimeType = c.getString(COL_MIMETYPE);
+                String data1 = c.getString(COL_DATA1);
+                String data2 = c.getString(COL_DATA2);
+                if (mimeType.equals(CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesStructuredName(data1, data2, MODE_INSERT_LOOKUP_DATA,
+                            candidates);
+                } else if (mimeType.equals(CommonDataKinds.Email.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesEmail(data2, MODE_INSERT_LOOKUP_DATA, candidates);
+                } else if (mimeType.equals(CommonDataKinds.Nickname.CONTENT_ITEM_TYPE)) {
+                    addMatchCandidatesNickname(data2, MODE_INSERT_LOOKUP_DATA, candidates);
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        for (int i = 0; i < candidates.mCount; i++) {
+            NameMatchCandidate candidate = candidates.mList.get(i);
+            mOpenHelper.insertNameLookup(contactId, candidate.mLookupType, candidate.mName);
+        }
+    }
+
+    /**
+     * Updates aggregate-level data from constituent contacts.
+     */
+    private void updateAggregateData(final SQLiteDatabase db, long aggregateId,
+            final ContentValues values) {
+        updateDisplayName(db, aggregateId, values);
+        updateSendToVoicemailAndRingtone(db, aggregateId);
+    }
+
+    /**
+     * Updates the aggregate record's {@link Aggregates#DISPLAY_NAME} field. If none of the
+     * constituent contacts has a suitable name, leaves the aggregate record unchanged.
+     */
+    private void updateDisplayName(SQLiteDatabase db, long aggregateId, ContentValues values) {
+        String displayName = getBestDisplayName(db, aggregateId);
+
+        // If don't have anything to base the display name on, let's just leave what was in
+        // that field hoping that there was something there before and it is still valid.
+        if (displayName == null) {
+            return;
+        }
+
+        values.clear();
+        values.put(Aggregates.DISPLAY_NAME, displayName);
+        db.update(Tables.AGGREGATES, values, Aggregates._ID + "=" + aggregateId, null);
+    }
+
+    /**
+     * Updates the various {@link AggregatesColumns} primary values based on the
+     * newly joined {@link Contacts} entry. If some aggregate primary values are
+     * unassigned, primary values from this contact will be promoted as the new
+     * super-primaries.
+     */
+    private void updatePrimaries(SQLiteDatabase db, long aggId, long contactId, boolean newAgg) {
+        Cursor cursor = null;
+
+        boolean hasOptimalPhone = false;
+        boolean hasFallbackPhone = false;
+        boolean hasOptimalEmail = false;
+        boolean hasFallbackEmail = false;
+
+        // Read currently recorded aggregate primary values
+        try {
+            cursor = db.query(Tables.AGGREGATES, Projections.PROJ_AGGREGATE_PRIMARIES,
+                    Aggregates._ID + "=" + aggId, null, null, null, null);
+            if (cursor.moveToNext()) {
+                hasOptimalPhone = (cursor.getLong(Projections.COL_OPTIMAL_PRIMARY_PHONE_ID) != 0);
+                hasFallbackPhone = (cursor.getLong(Projections.COL_FALLBACK_PRIMARY_PHONE_ID) != 0);
+                hasOptimalEmail = (cursor.getLong(Projections.COL_OPTIMAL_PRIMARY_EMAIL_ID) != 0);
+                hasFallbackEmail = (cursor.getLong(Projections.COL_FALLBACK_PRIMARY_EMAIL_ID) != 0);
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        long candidatePhone = 0;
+        long candidateEmail = 0;
+        long candidatePackageId = 0;
+        boolean candidateIsRestricted = false;
+
+        // Find primary data items from newly-joined contact, returning one
+        // candidate for each mimetype.
+        try {
+            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()) {
+                final long dataId = cursor.getLong(Projections.COL_DATA_ID);
+                final String mimeType = cursor.getString(Projections.COL_DATA_MIMETYPE);
+
+                candidatePackageId = cursor.getLong(Projections.COL_PACKAGE_ID);
+                candidateIsRestricted = (cursor.getInt(Projections.COL_IS_RESTRICTED) == 1);
+
+                if (CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                    candidatePhone = dataId;
+                } else if (CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                    candidateEmail = dataId;
+                }
+
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        final ContentValues values = new ContentValues();
+
+        // If a new aggregate, and single child is restricted, then mark
+        // aggregate as being protected by package. Otherwise set as null if
+        // multiple under aggregate or not restricted.
+        if (newAgg && candidateIsRestricted) {
+            values.put(AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID, candidatePackageId);
+        } else {
+            values.putNull(AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID);
+        }
+
+        // If newly joined contact has a primary phone number, consider
+        // promoting it up into aggregate as super-primary.
+        if (candidatePhone != 0) {
+            if (!hasOptimalPhone) {
+                values.put(AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID, candidatePhone);
+                values.put(AggregatesColumns.OPTIMAL_PRIMARY_PHONE_PACKAGE_ID, candidatePackageId);
+            }
+
+            // Also promote to unrestricted value, if none provided yet.
+            if (!hasFallbackPhone && !candidateIsRestricted) {
+                values.put(AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID, candidatePhone);
+            }
+        }
+
+        // If newly joined contact has a primary email, consider promoting it up
+        // into aggregate as super-primary.
+        if (candidateEmail != 0) {
+            if (!hasOptimalEmail) {
+                values.put(AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID, candidateEmail);
+                values.put(AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID, candidatePackageId);
+            }
+
+            // Also promote to unrestricted value, if none provided yet.
+            if (!hasFallbackEmail && !candidateIsRestricted) {
+                values.put(AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID, candidateEmail);
+            }
+        }
+
+        // Only write updated aggregate values if we made changes.
+        if (values.size() > 0) {
+            Log.d(TAG, "some sort of promotion is going on: " + values.toString());
+            db.update(Tables.AGGREGATES, values, Aggregates._ID + "=" + aggId, null);
+        }
+
+    }
+
+    /**
+     * Computes display name for the given aggregate.  Chooses a longer name over a shorter name
+     * and a mixed-case name over an all lowercase or uppercase name.
+     */
+    private String getBestDisplayName(SQLiteDatabase db, long aggregateId) {
+        String bestDisplayName = null;
+
+        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 + "'"),
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                String displayName = c.getString(0);
+                if (bestDisplayName == null) {
+                    bestDisplayName = displayName;
+                } else {
+                    if (NameNormalizer.compareComplexity(displayName, bestDisplayName) > 0) {
+                        bestDisplayName = displayName;
+                    }
+                }
+            }
+        } finally {
+            c.close();
+        }
+        return bestDisplayName;
+    }
+
+    /**
+     * Updates the aggregate's send-to-voicemail and custom-ringtone options based on
+     * constituent contacts' options.
+     */
+    private void updateSendToVoicemailAndRingtone(SQLiteDatabase db, long aggregateId) {
+        int totalContactCount = 0;
+        int sendToVoiceMailCount = 0;
+        String customRingtone = null;
+
+        final Cursor c = db.query(Tables.CONTACTS_JOIN_CONTACT_OPTIONS,
+                CONTACTS_JOIN_CONTACT_OPTIONS_COLUMNS,
+                Contacts.AGGREGATE_ID + "=" + aggregateId,
+                null, null, null, null);
+
+        try {
+            while (c.moveToNext()) {
+                totalContactCount++;
+                if (!c.isNull(COL_SEND_TO_VOICEMAIL)) {
+                    boolean sendToVoicemail = (c.getInt(COL_SEND_TO_VOICEMAIL) != 0);
+                    if (sendToVoicemail) {
+                        sendToVoiceMailCount++;
+                    }
+                }
+
+                if (customRingtone == null && !c.isNull(COL_CUSTOM_RINGTONE)) {
+                    customRingtone = c.getString(COL_CUSTOM_RINGTONE);
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        ContentValues values = new ContentValues(2);
+        values.put(Aggregates.SEND_TO_VOICEMAIL, totalContactCount == sendToVoiceMailCount);
+        values.put(Aggregates.CUSTOM_RINGTONE, customRingtone);
+
+        db.update(Tables.AGGREGATES, values, Aggregates._ID + "=" + aggregateId, null);
+    }
+
+    /**
+     * Finds matching aggregates and returns a cursor on those.
+     */
+    public Cursor queryAggregationSuggestions(long aggregateId, String[] projection,
+            HashMap<String, String> projectionMap, int maxSuggestions) {
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+
+        Cursor c;
+
+        // If this method is called in the middle of aggregation pass, we want to pause the
+        // aggregation, but not kill it.
+        db.beginTransaction();
+        try {
+            List<MatchScore> bestMatches = findMatchingAggregates(db, aggregateId, maxSuggestions);
+            c = queryMatchingAggregates(db, aggregateId, projection, projectionMap, bestMatches);
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        return c;
+    }
+
+    /**
+     * Loads aggregates with specified IDs and returns them in the order of IDs in the
+     * supplied list.
+     */
+    private Cursor queryMatchingAggregates(final SQLiteDatabase db, long aggregateId,
+            String[] projection, HashMap<String, String> projectionMap,
+            List<MatchScore> bestMatches) {
+
+        StringBuilder selection = new StringBuilder();
+        selection.append(Aggregates._ID);
+        selection.append(" IN (");
+        for (int i = 0; i < bestMatches.size(); i++) {
+            MatchScore matchScore = bestMatches.get(i);
+            if (i != 0) {
+                selection.append(",");
+            }
+            selection.append(matchScore.getAggregateId());
+        }
+        selection.append(")");
+
+        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        qb.setTables(Tables.AGGREGATES);
+        qb.setProjectionMap(projectionMap);
+
+        final Cursor cursor = qb.query(db, projection, selection.toString(), null, null, null,
+                Aggregates._ID);
+
+        ArrayList<Long> sortedAggregateIds = new ArrayList<Long>(bestMatches.size());
+        for (MatchScore matchScore : bestMatches) {
+            sortedAggregateIds.add(matchScore.getAggregateId());
+        }
+
+        Collections.sort(sortedAggregateIds);
+
+        int[] positionMap = new int[bestMatches.size()];
+        for (int i = 0; i < positionMap.length; i++) {
+            long id = bestMatches.get(i).getAggregateId();
+            positionMap[i] = sortedAggregateIds.indexOf(id);
+        }
+
+        return new ReorderingCursorWrapper(cursor, positionMap);
+    }
+
+    /**
+     * Finds aggregates with data matches and returns a list of {@link MatchScore}'s in the
+     * descending order of match score.
+     */
+    private List<MatchScore> findMatchingAggregates(final SQLiteDatabase db,
+            long aggregateId, int maxSuggestions) {
+
+        MatchCandidateList candidates = new MatchCandidateList();
+        ContactMatcher matcher = new ContactMatcher();
+
+        // Don't aggregate an aggregate with itself
+        matcher.keepOut(aggregateId);
+
+        final Cursor c = db.query(Tables.CONTACTS, CONTACT_ID_COLUMN,
+                Contacts.AGGREGATE_ID + "=" + aggregateId, null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long contactId = c.getLong(0);
+                updateMatchScoresBasedOnDataMatches(db, contactId, MODE_SUGGESTIONS, candidates,
+                        matcher);
+            }
+        } finally {
+            c.close();
+        }
+
+        List<MatchScore> matches = matcher.pickBestMatches(maxSuggestions,
+                ContactMatcher.SCORE_THRESHOLD_SUGGEST);
+
+        // TODO: remove the debug logging
+        Log.i(TAG, "MATCHES: " + matches);
+        return matches;
+    }
+
+    /**
+     * Various database projections used internally.
+     */
+    private interface Projections {
+        static final String[] PROJ_AGGREGATE_PRIMARIES = new String[] {
+                AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID,
+                AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID,
+                AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID,
+        };
+
+        static final int COL_OPTIMAL_PRIMARY_PHONE_ID = 0;
+        static final int COL_FALLBACK_PRIMARY_PHONE_ID = 1;
+        static final int COL_OPTIMAL_PRIMARY_EMAIL_ID = 2;
+        static final int COL_FALLBACK_PRIMARY_EMAIL_ID = 3;
+        static final int COL_SINGLE_RESTRICTED_PACKAGE_ID = 4;
+
+        static final String[] PROJ_DATA = new String[] {
+                Tables.DATA + "." + Data._ID,
+                Data.MIMETYPE,
+                Contacts.IS_RESTRICTED,
+                ContactsColumns.PACKAGE_ID,
+        };
+
+        static final int COL_DATA_ID = 0;
+        static final int COL_DATA_MIMETYPE = 1;
+        static final int COL_IS_RESTRICTED = 2;
+        static final int COL_PACKAGE_ID = 3;
+
+        static final String PRIMARY_MIME_CLAUSE = "(" + Data.MIMETYPE + "=\""
+                + CommonDataKinds.Phone.CONTENT_ITEM_TYPE + "\" OR " + Data.MIMETYPE + "=\""
+                + CommonDataKinds.Email.CONTENT_ITEM_TYPE + "\")";
+    }
+}
diff --git a/src/com/android/providers/contacts/ContactMatcher.java b/src/com/android/providers/contacts/ContactMatcher.java
new file mode 100644
index 0000000..d4b3349
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactMatcher.java
@@ -0,0 +1,471 @@
+/*
+ * 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.contacts;
+
+import com.android.providers.contacts.OpenHelper.NameLookupType;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * Logic for matching contacts' data and accumulating match scores.
+ */
+public class ContactMatcher {
+
+    // Best possible match score
+    public static final int MAX_SCORE = 100;
+
+    // Suggest to aggregate contacts if their match score is equal or greater than this threshold
+    public static final int SCORE_THRESHOLD_SUGGEST = 50;
+
+    // Automatically aggregate contacts if their match score is equal or greater than this threshold
+    public static final int SCORE_THRESHOLD_PRIMARY = 70;
+
+    // Automatically aggregate contacts if the match score is equal or greater than this threshold
+    // and there is a secondary match (phone number, email etc).
+    public static final int SCORE_THRESHOLD_SECONDARY = 50;
+
+    // Score for missing data (as opposed to present data but a bad match)
+    private static final int NO_DATA_SCORE = -1;
+
+    // Score for matching phone numbers
+    private static final int PHONE_MATCH_SCORE = 71;
+
+    // Score for matching email addresses
+    private static final int EMAIL_MATCH_SCORE = 71;
+
+    // Score for matching nickname
+    private static final int NICKNAME_MATCH_SCORE = 71;
+
+    // Minimum edit distance between two names to be considered an approximate match
+    public static final float APPROXIMATE_MATCH_THRESHOLD = 0.7f;
+
+    // Maximum number of characters in a name to be considered by the matching algorithm.
+    private static final int MAX_MATCHED_NAME_LENGTH = 12;
+
+    // Scores a multiplied by this number to allow room for "fractional" scores
+    private static final int SCORE_SCALE = 1000;
+
+
+    /**
+     * Name matching scores: a matrix by name type vs. candidate lookup type.
+     * For example, if the name type is "full name" while we are looking for a
+     * "full name", the score may be 99. If we are looking for a "nickname" but
+     * find "first name", the score may be 50 (see specific scores defined
+     * below.)
+     * <p>
+     * For approximate matching, we have a range of scores, let's say 40-70.  Depending one how
+     * similar the two strings are, the score will be somewhere between 40 and 70, with the exact
+     * match producing the score of 70.  The score may also be 0 if the similarity (distance)
+     * between the strings is below the threshold.
+     * <p>
+     * We use the Jaro-Winkler algorithm, which is particularly suited for
+     * name matching. See {@link JaroWinklerDistance}.
+     */
+    private static int[] sMinScore =
+            new int[NameLookupType.TYPE_COUNT * NameLookupType.TYPE_COUNT];
+    private static int[] sMaxScore =
+            new int[NameLookupType.TYPE_COUNT * NameLookupType.TYPE_COUNT];
+
+    /*
+     * Note: the reverse names ({@link NameLookupType#FULL_NAME_REVERSE},
+     * {@link NameLookupType#FULL_NAME_REVERSE_CONCATENATED} may appear to be redundant. They are
+     * not!  They are useful in three-way aggregation cases when we have, for example, both
+     * John Smith and Smith John.  A third contact with the name John Smith should be aggregated
+     * with the former rather than the latter.  This is why "reverse" matches have slightly lower
+     * scores than direct matches.
+     */
+    static {
+        setScoreRange(NameLookupType.FULL_NAME,
+                NameLookupType.FULL_NAME, 99, 99);
+        setScoreRange(NameLookupType.FULL_NAME,
+                NameLookupType.FULL_NAME_REVERSE, 90, 90);
+
+        setScoreRange(NameLookupType.FULL_NAME_REVERSE,
+                NameLookupType.FULL_NAME, 90, 90);
+        setScoreRange(NameLookupType.FULL_NAME_REVERSE,
+                NameLookupType.FULL_NAME_REVERSE, 99, 99);
+
+        setScoreRange(NameLookupType.FULL_NAME_CONCATENATED,
+                NameLookupType.FULL_NAME_CONCATENATED, 40, 80);
+        setScoreRange(NameLookupType.FULL_NAME_CONCATENATED,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 30, 70);
+        setScoreRange(NameLookupType.FULL_NAME_CONCATENATED,
+                NameLookupType.EMAIL_BASED_NICKNAME, 30, 60);
+        setScoreRange(NameLookupType.FULL_NAME_CONCATENATED,
+                NameLookupType.NICKNAME, 30, 60);
+
+        setScoreRange(NameLookupType.FULL_NAME_REVERSE_CONCATENATED,
+                NameLookupType.FULL_NAME_CONCATENATED, 30, 70);
+        setScoreRange(NameLookupType.FULL_NAME_REVERSE_CONCATENATED,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 40, 80);
+
+        setScoreRange(NameLookupType.FULL_NAME_WITH_NICKNAME,
+                NameLookupType.FULL_NAME_WITH_NICKNAME, 75, 75);
+        setScoreRange(NameLookupType.FULL_NAME_WITH_NICKNAME_REVERSE,
+                NameLookupType.FULL_NAME_WITH_NICKNAME_REVERSE, 73, 73);
+
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY,
+                NameLookupType.FAMILY_NAME_ONLY, 45, 75);
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY,
+                NameLookupType.FULL_NAME_CONCATENATED, 32, 72);
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 30, 70);
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY,
+                NameLookupType.EMAIL_BASED_NICKNAME, 30, 60);
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY,
+                NameLookupType.NICKNAME, 30, 60);
+
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME,
+                NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME, 71, 71);
+        setScoreRange(NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME,
+                NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME, 70, 70);
+
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY,
+                NameLookupType.GIVEN_NAME_ONLY, 40, 70);
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY,
+                NameLookupType.FULL_NAME_CONCATENATED, 32, 72);
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 30, 70);
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY,
+                NameLookupType.EMAIL_BASED_NICKNAME, 30, 60);
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY,
+                NameLookupType.NICKNAME, 30, 60);
+
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME,
+                NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME, 73, 73);
+        setScoreRange(NameLookupType.GIVEN_NAME_ONLY_AS_NICKNAME,
+                NameLookupType.FAMILY_NAME_ONLY_AS_NICKNAME, 70, 70);
+
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.EMAIL_BASED_NICKNAME, 30, 60);
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.GIVEN_NAME_ONLY, 30, 60);
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.FAMILY_NAME_ONLY, 30, 60);
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.FULL_NAME_CONCATENATED, 30, 60);
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 30, 60);
+        setScoreRange(NameLookupType.EMAIL_BASED_NICKNAME,
+                NameLookupType.NICKNAME, 30, 60);
+
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.NICKNAME, 30, 60);
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.GIVEN_NAME_ONLY, 30, 60);
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.FAMILY_NAME_ONLY, 30, 60);
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.FULL_NAME_CONCATENATED, 30, 60);
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.FULL_NAME_REVERSE_CONCATENATED, 30, 60);
+        setScoreRange(NameLookupType.NICKNAME,
+                NameLookupType.EMAIL_BASED_NICKNAME, 30, 60);
+    }
+
+    /**
+     * Populates the cells of the score matrix and score span matrix
+     * corresponding to the {@code candidateNameType} and {@code nameType}.
+     */
+    private static void setScoreRange(int candidateNameType, int nameType, int scoreFrom, int scoreTo) {
+        int index = nameType * NameLookupType.TYPE_COUNT + candidateNameType;
+        sMinScore[index] = scoreFrom;
+        sMaxScore[index] = scoreTo;
+    }
+
+    /**
+     * Returns the lower range for the match score for the given {@code candidateNameType} and
+     * {@code nameType}.
+     */
+    private static int getMinScore(int candidateNameType, int nameType) {
+        int index = nameType * NameLookupType.TYPE_COUNT + candidateNameType;
+        return sMinScore[index];
+    }
+
+    /**
+     * Returns the upper range for the match score for the given {@code candidateNameType} and
+     * {@code nameType}.
+     */
+    private static int getMaxScore(int candidateNameType, int nameType) {
+        int index = nameType * NameLookupType.TYPE_COUNT + candidateNameType;
+        return sMaxScore[index];
+    }
+
+    /**
+     * Captures the max score and match count for a specific aggregate.  Used in an
+     * aggregateId - MatchScore map.
+     */
+    public static class MatchScore implements Comparable<MatchScore> {
+        private long mAggregateId;
+        private boolean mKeepIn;
+        private boolean mKeepOut;
+        private int mPrimaryScore;
+        private int mSecondaryScore;
+        private int mMatchCount;
+
+        public MatchScore(long aggregateId) {
+            this.mAggregateId = aggregateId;
+        }
+
+        public void reset(long aggregateId) {
+            this.mAggregateId = aggregateId;
+            mKeepIn = false;
+            mKeepOut = false;
+            mPrimaryScore = 0;
+            mSecondaryScore = 0;
+            mMatchCount = 0;
+        }
+
+        public long getAggregateId() {
+            return mAggregateId;
+        }
+
+        public void updatePrimaryScore(int score) {
+            if (score > mPrimaryScore) {
+                mPrimaryScore = score;
+            }
+            mMatchCount++;
+        }
+
+        public void updateSecondaryScore(int score) {
+            if (score > mSecondaryScore) {
+                mSecondaryScore = score;
+            }
+            mMatchCount++;
+        }
+
+        public void keepIn() {
+            mKeepIn = true;
+        }
+
+        public void keepOut() {
+            mKeepOut = true;
+        }
+
+        public int getScore() {
+            if (mKeepOut) {
+                return 0;
+            }
+
+            if (mKeepIn) {
+                return MAX_SCORE;
+            }
+
+            int score = (mPrimaryScore > mSecondaryScore ? mPrimaryScore : mSecondaryScore);
+
+            // Ensure that of two aggregates with the same match score the one with more matching
+            // data elements wins.
+            return score * SCORE_SCALE + mMatchCount;
+        }
+
+        /**
+         * Descending order of match score.
+         */
+        public int compareTo(MatchScore another) {
+            return another.getScore() - getScore();
+        }
+
+        @Override
+        public String toString() {
+            return mAggregateId + ": " + mPrimaryScore + "/" + mSecondaryScore + "(" + mMatchCount
+                    + ")";
+        }
+    }
+
+    private final HashMap<Long, MatchScore> mScores = new HashMap<Long, MatchScore>();
+    private final ArrayList<MatchScore> mScoreList = new ArrayList<MatchScore>();
+    private int mScoreCount = 0;
+
+    private final JaroWinklerDistance mJaroWinklerDistance =
+            new JaroWinklerDistance(MAX_MATCHED_NAME_LENGTH);
+
+    private MatchScore getMatchingScore(long aggregateId) {
+        MatchScore matchingScore = mScores.get(aggregateId);
+        if (matchingScore == null) {
+            if (mScoreList.size() > mScoreCount) {
+                matchingScore = mScoreList.get(mScoreCount);
+                matchingScore.reset(aggregateId);
+            } else {
+                matchingScore = new MatchScore(aggregateId);
+                mScoreList.add(matchingScore);
+            }
+            mScoreCount++;
+            mScores.put(aggregateId, matchingScore);
+        }
+        return matchingScore;
+    }
+
+    /**
+     * Checks if there is a match and updates the overall score for the
+     * specified aggregate for a discovered match. The new score is determined
+     * by the prior score, by the type of name we were looking for, the type
+     * of name we found and, if the match is approximate, the distance between the candidate and
+     * actual name.
+     */
+    public void matchName(long aggregateId, int candidateNameType, String candidateName,
+            int nameType, String name, boolean approximate) {
+        int maxScore = getMaxScore(candidateNameType, nameType);
+        if (maxScore == 0) {
+            return;
+        }
+
+        if (candidateName.equals(name)) {
+            updatePrimaryScore(aggregateId, maxScore);
+            return;
+        }
+
+        if (!approximate) {
+            return;
+        }
+
+        int minScore = getMinScore(candidateNameType, nameType);
+        if (minScore == maxScore) {
+            return;
+        }
+
+        float distance = mJaroWinklerDistance.getDistance(
+                Hex.decodeHex(candidateName), Hex.decodeHex(name));
+
+        int score;
+        if (distance > APPROXIMATE_MATCH_THRESHOLD) {
+            float adjustedDistance = (distance - APPROXIMATE_MATCH_THRESHOLD)
+                    / (1f - APPROXIMATE_MATCH_THRESHOLD);
+            score = (int)(minScore +  (maxScore - minScore) * adjustedDistance);
+        } else {
+            score = 0;
+        }
+
+        updatePrimaryScore(aggregateId, score);
+    }
+
+    public void updateScoreWithPhoneNumberMatch(long aggregateId) {
+        updateSecondaryScore(aggregateId, PHONE_MATCH_SCORE);
+    }
+
+    public void updateScoreWithEmailMatch(long aggregateId) {
+        updateSecondaryScore(aggregateId, EMAIL_MATCH_SCORE);
+    }
+
+    public void updateScoreWithNicknameMatch(long aggregateId) {
+        updateSecondaryScore(aggregateId, NICKNAME_MATCH_SCORE);
+    }
+
+    private void updatePrimaryScore(long aggregateId, int score) {
+        getMatchingScore(aggregateId).updatePrimaryScore(score);
+    }
+
+    private void updateSecondaryScore(long aggregateId, int score) {
+        getMatchingScore(aggregateId).updateSecondaryScore(score);
+    }
+
+    public void keepIn(long aggregateId) {
+        getMatchingScore(aggregateId).keepIn();
+    }
+
+    public void keepOut(long aggregateId) {
+        getMatchingScore(aggregateId).keepOut();
+    }
+
+    public void clear() {
+        mScores.clear();
+        mScoreCount = 0;
+    }
+
+    /**
+     * Returns a list of IDs for aggregates that are matched on secondary data elements
+     * (phone number, email address, nickname). We still need to obtain the approximate
+     * primary score for those aggregates to determine if any of them should be aggregated.
+     * <p>
+     * May return null.
+     */
+    public List<Long> prepareSecondaryMatchCandidates(int threshold) {
+        ArrayList<Long> aggregateIds = null;
+
+        for (int i = 0; i < mScoreCount; i++) {
+            MatchScore score = mScoreList.get(i);
+            if (score.mKeepOut) {
+                continue;
+            }
+
+            int s = score.mSecondaryScore;
+            if (s >= threshold) {
+                if (aggregateIds == null) {
+                    aggregateIds = new ArrayList<Long>();
+                }
+                aggregateIds.add(score.mAggregateId);
+                score.mPrimaryScore = NO_DATA_SCORE;
+            }
+        }
+        return aggregateIds;
+    }
+
+    /**
+     * Returns the aggregateId with the best match score over the specified threshold or -1
+     * if no such aggregate is found.
+     */
+    public long pickBestMatch(int threshold) {
+        long aggregateId = -1;
+        int maxScore = 0;
+        for (int i = 0; i < mScoreCount; i++) {
+            MatchScore score = mScoreList.get(i);
+            if (score.mKeepIn) {
+                return score.mAggregateId;
+            }
+
+            if (score.mKeepOut) {
+                continue;
+            }
+
+            int s = score.mPrimaryScore;
+            if (s == NO_DATA_SCORE) {
+                s = score.mSecondaryScore;
+            }
+
+            if (s >= threshold && s > maxScore) {
+                aggregateId = score.mAggregateId;
+                maxScore = s;
+            }
+        }
+        return aggregateId;
+    }
+
+    /**
+     * Returns up to {@code maxSuggestions} best scoring matches.
+     */
+    public List<MatchScore> pickBestMatches(int maxSuggestions, int threshold) {
+        int scaledThreshold = threshold * SCORE_SCALE;
+        List<MatchScore> matches = mScoreList.subList(0, mScoreCount);
+        Collections.sort(matches);
+        int count = 0;
+        for (int i = 0; i < mScoreCount; i++) {
+            MatchScore matchScore = matches.get(i);
+            if (matchScore.getScore() >= scaledThreshold) {
+                count++;
+            } else {
+                break;
+            }
+        }
+
+        if (count > maxSuggestions) {
+            count = maxSuggestions;
+        }
+
+        return matches.subList(0, count);
+    }
+}
diff --git a/src/com/android/providers/contacts/ContactsProvider2.java b/src/com/android/providers/contacts/ContactsProvider2.java
new file mode 100644
index 0000000..49975ed
--- /dev/null
+++ b/src/com/android/providers/contacts/ContactsProvider2.java
@@ -0,0 +1,2134 @@
+/*
+ * 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.contacts;
+
+import com.android.providers.contacts.OpenHelper.AggregatesColumns;
+import com.android.providers.contacts.OpenHelper.AggregationExceptionColumns;
+import com.android.providers.contacts.OpenHelper.Clauses;
+import com.android.providers.contacts.OpenHelper.ContactsColumns;
+import com.android.providers.contacts.OpenHelper.ContactOptionsColumns;
+import com.android.providers.contacts.OpenHelper.DataColumns;
+import com.android.providers.contacts.OpenHelper.GroupsColumns;
+import com.android.providers.contacts.OpenHelper.MimetypesColumns;
+import com.android.providers.contacts.OpenHelper.PhoneLookupColumns;
+import com.android.providers.contacts.OpenHelper.Tables;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.accounts.OnAccountsUpdatedListener;
+import android.content.ContentProvider;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Entity;
+import android.content.EntityIterator;
+import android.content.OperationApplicationException;
+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;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.RemoteException;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.provider.SocialContract;
+import android.provider.Contacts.ContactMethods;
+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.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;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+
+/**
+ * Contacts content provider. The contract between this provider and applications
+ * is defined in {@link ContactsContract}.
+ */
+public class ContactsProvider2 extends ContentProvider implements OnAccountsUpdatedListener {
+    // TODO: clean up debug tag and rename this class
+    private static final String TAG = "ContactsProvider ~~~~";
+
+    // TODO: define broadcastreceiver to catch app uninstalls that should clear exceptions
+    // TODO: carefully prevent all incoming nested queries; they can be gaping security holes
+    // TODO: check for restricted flag during insert(), update(), and delete() calls
+
+    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final String STREQUENT_ORDER_BY = Aggregates.STARRED + " DESC, "
+            + Aggregates.TIMES_CONTACTED + " DESC, "
+            + Aggregates.DISPLAY_NAME + " ASC";
+    private static final String STREQUENT_LIMIT =
+            "(SELECT COUNT(1) FROM " + Tables.AGGREGATES + " WHERE "
+            + Aggregates.STARRED + "=1) + 25";
+
+    private static final int AGGREGATES = 1000;
+    private static final int AGGREGATES_ID = 1001;
+    private static final int AGGREGATES_DATA = 1002;
+    private static final int AGGREGATES_SUMMARY = 1003;
+    private static final int AGGREGATES_SUMMARY_ID = 1004;
+    private static final int AGGREGATES_SUMMARY_FILTER = 1005;
+    private static final int AGGREGATES_SUMMARY_STREQUENT = 1006;
+    private static final int AGGREGATES_SUMMARY_STREQUENT_FILTER = 1007;
+
+    private static final int CONTACTS = 2002;
+    private static final int CONTACTS_ID = 2003;
+    private static final int CONTACTS_DATA = 2004;
+    private static final int CONTACTS_FILTER_EMAIL = 2005;
+
+    private static final int DATA = 3000;
+    private static final int DATA_ID = 3001;
+    private static final int PHONES = 3002;
+    private static final int PHONES_FILTER = 3003;
+    private static final int POSTALS = 3004;
+
+    private static final int PHONE_LOOKUP = 4000;
+
+    private static final int ACCOUNTS = 5000;
+    private static final int ACCOUNTS_ID = 5001;
+
+    private static final int AGGREGATION_EXCEPTIONS = 6000;
+    private static final int AGGREGATION_EXCEPTION_ID = 6001;
+
+    private static final int PRESENCE = 7000;
+    private static final int PRESENCE_ID = 7001;
+
+    private static final int AGGREGATION_SUGGESTIONS = 8000;
+
+    private static final int RESTRICTION_EXCEPTIONS = 9000;
+
+    private static final int GROUPS = 10000;
+    private static final int GROUPS_ID = 10001;
+    private static final int GROUPS_SUMMARY = 10003;
+
+    private interface Projections {
+        public static final String[] PROJ_CONTACTS = new String[] {
+            ContactsColumns.CONCRETE_ID,
+        };
+
+        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,
+        };
+
+        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;
+
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sAggregatesProjectionMap;
+    /** Contains the aggregate columns along with primary phone */
+    private static final HashMap<String, String> sAggregatesSummaryProjectionMap;
+    /** Contains the data, contacts, and aggregate columns, for joined tables. */
+    private static final HashMap<String, String> sDataContactsAggregateProjectionMap;
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sContactsProjectionMap;
+    /** Contains just the data columns */
+    private static final HashMap<String, String> sDataProjectionMap;
+    /** Contains the data and contacts columns, for joined tables */
+    private static final HashMap<String, String> sDataContactsProjectionMap;
+    /** Contains the data and contacts columns, for joined tables */
+    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 */
+    private static final HashMap<String, String> sRestrictionExceptionsProjectionMap;
+
+    private static final HashMap<Account, Long> sAccountsToIdMap = new HashMap<Account, Long>();
+    private static final HashMap<Long, Account> sIdToAccountsMap = new HashMap<Long, Account>();
+
+    /** Sql select statement that returns the contact id associated with a data record. */
+    private static final String sNestedContactIdSelect;
+    /** Sql select statement that returns the mimetype id associated with a data record. */
+    private static final String sNestedMimetypeSelect;
+    /** Sql select statement that returns the aggregate id associated with a contact record. */
+    private static final String sNestedAggregateIdSelect;
+    /** Sql select statement that returns a list of contact ids associated with an aggregate record. */
+    private static final String sNestedContactIdListSelect;
+    /** Sql where statement used to match all the data records that need to be updated when a new
+     * "primary" is selected.*/
+    private static final String sSetPrimaryWhere;
+    /** Sql where statement used to match all the data records that need to be updated when a new
+     * "super primary" is selected.*/
+    private static final String sSetSuperPrimaryWhere;
+    /** Precompiled sql statement for setting a data record to the primary. */
+    private SQLiteStatement mSetPrimaryStatement;
+    /** Precomipled sql statement for setting a data record to the super primary. */
+    private SQLiteStatement mSetSuperPrimaryStatement;
+
+    private static final String GTALK_PROTOCOL_STRING = ContactMethods
+            .encodePredefinedImProtocol(ContactMethods.PROTOCOL_GOOGLE_TALK);
+
+    static {
+        // Contacts URI matching table
+        final UriMatcher matcher = sUriMatcher;
+        matcher.addURI(ContactsContract.AUTHORITY, "accounts", ACCOUNTS);
+        matcher.addURI(ContactsContract.AUTHORITY, "accounts/#", ACCOUNTS_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates", AGGREGATES);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates/#", AGGREGATES_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates/#/data", AGGREGATES_DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates_summary", AGGREGATES_SUMMARY);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates_summary/#", AGGREGATES_SUMMARY_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates_summary/filter/*",
+                AGGREGATES_SUMMARY_FILTER);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates_summary/strequent/",
+                AGGREGATES_SUMMARY_STREQUENT);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates_summary/strequent/filter/*",
+                AGGREGATES_SUMMARY_STREQUENT_FILTER);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregates/#/suggestions",
+                AGGREGATION_SUGGESTIONS);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts", CONTACTS);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/#", CONTACTS_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/#/data", CONTACTS_DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "contacts/filter_email/*",
+                CONTACTS_FILTER_EMAIL);
+
+        matcher.addURI(ContactsContract.AUTHORITY, "data", DATA);
+        matcher.addURI(ContactsContract.AUTHORITY, "data/#", DATA_ID);
+        matcher.addURI(ContactsContract.AUTHORITY, "data/phones", PHONES);
+        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);
+        matcher.addURI(ContactsContract.AUTHORITY, "aggregation_exceptions/*",
+                AGGREGATION_EXCEPTION_ID);
+
+        matcher.addURI(ContactsContract.AUTHORITY, "presence", PRESENCE);
+        matcher.addURI(ContactsContract.AUTHORITY, "presence/#", PRESENCE_ID);
+
+        matcher.addURI(ContactsContract.AUTHORITY, "restriction_exceptions", RESTRICTION_EXCEPTIONS);
+
+        HashMap<String, String> columns;
+
+        // Accounts projection map
+        columns = new HashMap<String, String>();
+        columns.put(Accounts._ID, "accounts._id AS _id");
+        columns.put(Accounts.NAME, Accounts.NAME);
+        columns.put(Accounts.TYPE, Accounts.TYPE);
+        columns.put(Accounts.DATA1, Accounts.DATA1);
+        columns.put(Accounts.DATA2, Accounts.DATA2);
+        columns.put(Accounts.DATA3, Accounts.DATA3);
+        columns.put(Accounts.DATA4, Accounts.DATA4);
+        columns.put(Accounts.DATA5, Accounts.DATA5);
+        sAccountsProjectionMap = columns;
+
+        // Aggregates projection map
+        columns = new HashMap<String, String>();
+        columns.put(Aggregates._ID, "aggregates._id AS _id");
+        columns.put(Aggregates.DISPLAY_NAME, Aggregates.DISPLAY_NAME);
+        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);
+        columns.put(Aggregates.SEND_TO_VOICEMAIL, Aggregates.SEND_TO_VOICEMAIL);
+        columns.put(AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID);
+        columns.put(AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID,
+                AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID);
+        sAggregatesProjectionMap = columns;
+
+        // Aggregates primaries projection map. The overall presence status is
+        // the most-present value, as indicated by the largest value.
+        columns = new HashMap<String, String>();
+        columns.putAll(sAggregatesProjectionMap);
+        columns.put(CommonDataKinds.Phone.TYPE, CommonDataKinds.Phone.TYPE);
+        columns.put(CommonDataKinds.Phone.LABEL, CommonDataKinds.Phone.LABEL);
+        columns.put(CommonDataKinds.Phone.NUMBER, CommonDataKinds.Phone.NUMBER);
+        columns.put(Presence.PRESENCE_STATUS, "MAX(" + Presence.PRESENCE_STATUS + ")");
+        sAggregatesSummaryProjectionMap = columns;
+
+        // Contacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(Contacts._ID, "contacts._id AS _id");
+        columns.put(Contacts.PACKAGE, Contacts.PACKAGE);
+        columns.put(Contacts.AGGREGATE_ID, Contacts.AGGREGATE_ID);
+        columns.put(Accounts.NAME, Accounts.NAME);
+        columns.put(Accounts.TYPE, Accounts.TYPE);
+        columns.put(Contacts.SOURCE_ID, Contacts.SOURCE_ID);
+        columns.put(Contacts.VERSION, Contacts.VERSION);
+        columns.put(Contacts.DIRTY, Contacts.DIRTY);
+        sContactsProjectionMap = columns;
+
+        // Data projection map
+        columns = new HashMap<String, String>();
+        columns.put(Data._ID, "data._id AS _id");
+        columns.put(Data.CONTACT_ID, Data.CONTACT_ID);
+        columns.put(Data.MIMETYPE, Data.MIMETYPE);
+        columns.put(Data.IS_PRIMARY, Data.IS_PRIMARY);
+        columns.put(Data.IS_SUPER_PRIMARY, Data.IS_SUPER_PRIMARY);
+        columns.put(Data.DATA_VERSION, Data.DATA_VERSION);
+        columns.put(Data.DATA1, "data.data1 as data1");
+        columns.put(Data.DATA2, "data.data2 as data2");
+        columns.put(Data.DATA3, "data.data3 as data3");
+        columns.put(Data.DATA4, "data.data4 as data4");
+        columns.put(Data.DATA5, "data.data5 as data5");
+        columns.put(Data.DATA6, "data.data6 as data6");
+        columns.put(Data.DATA7, "data.data7 as data7");
+        columns.put(Data.DATA8, "data.data8 as data8");
+        columns.put(Data.DATA9, "data.data9 as data9");
+        columns.put(Data.DATA10, "data.data10 as data10");
+        // Mappings used for backwards compatibility.
+        columns.put("number", Phone.NUMBER);
+        sDataProjectionMap = columns;
+
+        // Data and contacts projection map for joins. _id comes from the data table
+        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, DataColumns.CONCRETE_CONTACT_ID);
+        sDataContactsProjectionMap = columns;
+
+        columns = new HashMap<String, String>();
+        columns.put(Accounts.NAME, Accounts.NAME);
+        columns.put(Accounts.TYPE, Accounts.TYPE);
+        columns.putAll(sDataContactsProjectionMap);
+        sDataContactsAccountsProjectionMap = columns;
+
+        // Data and contacts projection map for joins. _id comes from the data table
+        columns = new HashMap<String, String>();
+        columns.putAll(sAggregatesProjectionMap);
+        columns.putAll(sContactsProjectionMap); //
+        columns.putAll(sDataProjectionMap); // _id will be replaced with the one from data
+        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");
+        columns.put(AggregationExceptions.TYPE, AggregationExceptions.TYPE);
+        columns.put(AggregationExceptions.AGGREGATE_ID,
+                "contacts1." + Contacts.AGGREGATE_ID + " AS " + AggregationExceptions.AGGREGATE_ID);
+        columns.put(AggregationExceptions.CONTACT_ID, AggregationExceptionColumns.CONTACT_ID2);
+        sAggregationExceptionsProjectionMap = columns;
+
+        // Restriction exception projection map
+        columns = new HashMap<String, String>();
+        columns.put(RestrictionExceptions.PACKAGE_PROVIDER, RestrictionExceptions.PACKAGE_PROVIDER);
+        columns.put(RestrictionExceptions.PACKAGE_CLIENT, RestrictionExceptions.PACKAGE_CLIENT);
+        columns.put(RestrictionExceptions.ALLOW_ACCESS, "1"); // Access granted if row returned
+        sRestrictionExceptionsProjectionMap = columns;
+
+        sNestedContactIdSelect = "SELECT " + Data.CONTACT_ID + " FROM " + Tables.DATA + " WHERE "
+                + Data._ID + "=?";
+        sNestedMimetypeSelect = "SELECT " + DataColumns.MIMETYPE_ID + " FROM " + Tables.DATA
+                + " WHERE " + Data._ID + "=?";
+        sNestedAggregateIdSelect = "SELECT " + Contacts.AGGREGATE_ID + " FROM " + Tables.CONTACTS
+                + " WHERE " + Contacts._ID + "=(" + sNestedContactIdSelect + ")";
+        sNestedContactIdListSelect = "SELECT " + Contacts._ID + " FROM " + Tables.CONTACTS
+                + " WHERE " + Contacts.AGGREGATE_ID + "=(" + sNestedAggregateIdSelect + ")";
+        sSetPrimaryWhere = Data.CONTACT_ID + "=(" + sNestedContactIdSelect + ") AND "
+                + DataColumns.MIMETYPE_ID + "=(" + sNestedMimetypeSelect + ")";
+        sSetSuperPrimaryWhere  = Data.CONTACT_ID + " IN (" + sNestedContactIdListSelect + ") AND "
+                + DataColumns.MIMETYPE_ID + "=(" + sNestedMimetypeSelect + ")";
+    }
+
+    private final ContactAggregationScheduler mAggregationScheduler;
+    private OpenHelper mOpenHelper;
+    private static final AccountComparator sAccountComparator = new AccountComparator();
+
+    private ContactAggregator mContactAggregator;
+    private NameSplitter mNameSplitter;
+
+    public ContactsProvider2() {
+        this(new ContactAggregationScheduler());
+    }
+
+    /**
+     * Constructor for testing.
+     */
+    /* package */ ContactsProvider2(ContactAggregationScheduler scheduler) {
+        mAggregationScheduler = scheduler;
+    }
+
+    @Override
+    public boolean onCreate() {
+        final Context context = getContext();
+        mOpenHelper = getOpenHelper(context);
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+
+        loadAccountsMaps();
+
+        mContactAggregator = new ContactAggregator(context, mOpenHelper, mAggregationScheduler);
+
+        mSetPrimaryStatement = db.compileStatement(
+                "UPDATE " + Tables.DATA + " SET " + Data.IS_PRIMARY
+                + "=(_id=?) WHERE " + sSetPrimaryWhere);
+        mSetSuperPrimaryStatement = db.compileStatement(
+                "UPDATE " + Tables.DATA + " SET " + Data.IS_SUPER_PRIMARY
+                + "=(_id=?) WHERE " + sSetSuperPrimaryWhere);
+
+        mNameSplitter = new NameSplitter(
+                context.getString(com.android.internal.R.string.common_name_prefixes),
+                context.getString(com.android.internal.R.string.common_last_name_prefixes),
+                context.getString(com.android.internal.R.string.common_name_suffixes),
+                context.getString(com.android.internal.R.string.common_name_conjunctions));
+
+        return (db != null);
+    }
+
+    /* Visible for testing */
+    protected OpenHelper getOpenHelper(final Context context) {
+        return OpenHelper.getInstance(context);
+    }
+
+    @Override
+    protected void finalize() throws Throwable {
+        if (mContactAggregator != null) {
+            mContactAggregator.quit();
+        }
+
+        super.finalize();
+    }
+
+    /**
+     * Wipes all data from the contacts database.
+     */
+    /* package */ void wipeData() {
+        mOpenHelper.wipeData();
+    }
+
+    /**
+     * Read the rows from the accounts table and populate the in-memory accounts maps.
+     */
+    private void loadAccountsMaps() {
+        synchronized (sAccountsToIdMap) {
+            sAccountsToIdMap.clear();
+            sIdToAccountsMap.clear();
+            Cursor c = mOpenHelper.getReadableDatabase().query(Tables.ACCOUNTS,
+                    new String[]{Accounts._ID, Accounts.NAME, Accounts.TYPE},
+                    null, null, null, null, null);
+            try {
+                while (c.moveToNext()) {
+                    addToAccountsMaps(c.getLong(0), new Account(c.getString(1), c.getString(2)));
+                }
+            } finally {
+                c.close();
+            }
+        }
+    }
+
+    /**
+     * Return the Accounts rowId that matches the account that is passed in or null if
+     * no match exists. If refreshIfNotFound is set then if the account cannot be found in the
+     * map then the AccountManager will be queried synchronously for the current set of
+     * accounts.
+     */
+    private Long readAccountByName(Account account, boolean refreshIfNotFound) {
+        synchronized (sAccountsToIdMap) {
+            Long id = sAccountsToIdMap.get(account);
+            if (id == null && refreshIfNotFound) {
+                onAccountsUpdated(AccountManager.get(getContext()).blockingGetAccounts());
+                id = sAccountsToIdMap.get(account);
+            }
+            return id;
+        }
+    }
+
+    /**
+     * Return the Account that has the specified rowId or null if it does not exist.
+     */
+    private Account readAccountById(long id) {
+        synchronized (sAccountsToIdMap) {
+            return sIdToAccountsMap.get(id);
+        }
+    }
+
+    /**
+     * Add the contents from the Accounts row to the accounts maps.
+     */
+    private void addToAccountsMaps(long id, Account account) {
+        synchronized (sAccountsToIdMap) {
+            sAccountsToIdMap.put(account, id);
+            sIdToAccountsMap.put(id, account);
+        }
+    }
+
+    /**
+     * Reads the current set of accounts from the AccountManager and makes the local
+     * Accounts table and the in-memory accounts maps consistent with it.
+     */
+    public void onAccountsUpdated(Account[] accounts) {
+        synchronized (sAccountsToIdMap) {
+            Arrays.sort(accounts);
+
+            // if there is an account in the array that we don't know about yet add it to our
+            // cache and our database copy of accounts
+            final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+            for (Account account : accounts) {
+                if (readAccountByName(account, false /* refreshIfNotFound */) == null) {
+                    // add this account
+                    ContentValues values = new ContentValues();
+                    values.put(Accounts.NAME, account.mName);
+                    values.put(Accounts.TYPE, account.mType);
+                    long id = db.insert(Tables.ACCOUNTS, Accounts.NAME, values);
+                    if (id < 0) {
+                        throw new IllegalStateException("error inserting account in db");
+                    }
+                    addToAccountsMaps(id, account);
+                }
+            }
+
+            ArrayList<Account> accountsToRemove = new ArrayList<Account>();
+            // now check our list of accounts and remove any that are not in the array
+            for (Account account : sAccountsToIdMap.keySet()) {
+                if (Arrays.binarySearch(accounts, account, sAccountComparator) < 0) {
+                    accountsToRemove.add(account);
+                }
+            }
+
+            for (Account account : accountsToRemove) {
+                final Long id = sAccountsToIdMap.remove(account);
+                sIdToAccountsMap.remove(id);
+                db.delete(Tables.ACCOUNTS, Accounts._ID + "=" + id, null);
+            }
+        }
+    }
+
+    private static class AccountComparator implements Comparator<Account> {
+        public int compare(Account object1, Account object2) {
+            if (object1 == object2) {
+                return 0;
+            }
+            int result = object1.mType.compareTo(object2.mType);
+            if (result != 0) {
+                return result;
+            }
+            return object1.mName.compareTo(object2.mName);
+        }
+    }
+
+    /**
+     * Called when a change has been made.
+     *
+     * @param uri the uri that the change was made to
+     */
+    private void onChange(Uri uri) {
+        getContext().getContentResolver().notifyChange(ContactsContract.AUTHORITY_URI, null);
+    }
+
+    @Override
+    public boolean isTemporary() {
+        return false;
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        final int match = sUriMatcher.match(uri);
+        long id = 0;
+        switch (match) {
+            case ACCOUNTS: {
+                id = insertAccountData(values);
+                break;
+            }
+
+            case AGGREGATES: {
+                id = insertAggregate(values);
+                break;
+            }
+
+            case CONTACTS: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertContact(values, account);
+                break;
+            }
+
+            case CONTACTS_DATA: {
+                final Account account = readAccountFromQueryParams(uri);
+                values.put(Data.CONTACT_ID, uri.getPathSegments().get(1));
+                id = insertData(values, account);
+                break;
+            }
+
+            case DATA: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertData(values, account);
+                break;
+            }
+
+            case GROUPS: {
+                final Account account = readAccountFromQueryParams(uri);
+                id = insertGroup(values, account);
+                break;
+            }
+
+            case PRESENCE: {
+                id = insertPresence(values);
+                break;
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        if (id < 0) {
+            return null;
+        }
+
+        final Uri result = ContentUris.withAppendedId(uri, id);
+        onChange(result);
+        return result;
+    }
+
+    /**
+     * Inserts an item in the accounts table
+     *
+     * @param values the values for the new row
+     * @return the row ID of the newly created row
+     */
+    private long insertAccountData(ContentValues values) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        return db.insert(Tables.ACCOUNTS, Accounts.DATA1, values);
+    }
+
+    /**
+     * Inserts an item in the aggregates table
+     *
+     * @param values the values for the new row
+     * @return the row ID of the newly created row
+     */
+    private long insertAggregate(ContentValues values) {
+        throw new UnsupportedOperationException("Aggregates are created automatically");
+    }
+
+    /**
+     * Inserts an item in the contacts table
+     *
+     * @param values the values for the new row
+     * @param account the account this contact should be associated with. may be null.
+     * @return the row ID of the newly created row
+     */
+    private long insertContact(ContentValues values, Account account) {
+        /*
+         * The contact record is inserted in the contacts table, but it needs to
+         * be processed by the aggregator before it will be returned by the
+         * "aggregates" queries.
+         */
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        ContentValues overriddenValues = new ContentValues(values);
+        overriddenValues.putNull(Contacts.AGGREGATE_ID);
+        if (!resolveAccount(overriddenValues, account)) {
+            return -1;
+        }
+
+        // Replace package with internal mapping
+        final String packageName = overriddenValues.getAsString(Contacts.PACKAGE);
+        overriddenValues.put(ContactsColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+        overriddenValues.remove(Contacts.PACKAGE);
+
+        long rowId = db.insert(Tables.CONTACTS, Contacts.AGGREGATE_ID, overriddenValues);
+
+        mContactAggregator.schedule();
+
+        return rowId;
+    }
+
+    /**
+     * If an account name or type is specified in values then create an Account from it or
+     * use the account that is passed in, if account is non-null, then look up the Accounts
+     * rowId that corresponds to the Account. Then insert
+     * the Accounts rowId into the values with key {@link Contacts#ACCOUNTS_ID}. Remove any
+     * value for {@link Accounts#NAME} or {@link Accounts#TYPE} from the values.
+     * @param values the ContentValues to read from and update
+     * @param account the Account to resolve. may be null.
+     * @return false if an account was present in the values that is not in the Accounts table
+     */
+    private boolean resolveAccount(ContentValues values, Account account) {
+        // If an account name and type is specified then resolve it into an accounts_id.
+        // If either is specified then both must be specified.
+        final String accountName = values.getAsString(Accounts.NAME);
+        final String accountType = values.getAsString(Accounts.TYPE);
+        if (!TextUtils.isEmpty(accountName) || !TextUtils.isEmpty(accountType)) {
+            final Account valuesAccount = new Account(accountName, accountType);
+            if (account != null && !valuesAccount.equals(account)) {
+                throw new IllegalArgumentException("account in params doesn't match account in "
+                        + "values: " + account + "!=" + valuesAccount);
+            }
+            account = valuesAccount;
+        }
+        if (account != null) {
+            final Long accountId = readAccountByName(account, true /* refreshIfNotFound */);
+            if (accountId == null) {
+                // an invalid account was passed in or the account was deleted after this
+                // request was made. fail this request.
+                return false;
+            }
+            values.put(Contacts.ACCOUNTS_ID, accountId);
+        }
+        values.remove(Accounts.NAME);
+        values.remove(Accounts.TYPE);
+        return true;
+    }
+
+    /**
+     * Inserts an item in the data table
+     *
+     * @param values the values for the new row
+     * @param account the account this data row should be associated with. may be null.
+     * @return the row ID of the newly created row
+     */
+    private long insertData(ContentValues values, Account account) {
+        boolean success = false;
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        long id = 0;
+        db.beginTransaction();
+        try {
+            long contactId = values.getAsLong(Data.CONTACT_ID);
+
+            // Replace mimetype with internal mapping
+            final String mimeType = values.getAsString(Data.MIMETYPE);
+            values.put(DataColumns.MIMETYPE_ID, mOpenHelper.getMimeTypeId(mimeType));
+            values.remove(Data.MIMETYPE);
+
+            if (StructuredName.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                parseStructuredName(values);
+            }
+
+            // Insert the data row itself
+            id = db.insert(Tables.DATA, Data.DATA1, values);
+
+            // If it's a phone number add the normalized version to the lookup table
+            if (Phone.CONTENT_ITEM_TYPE.equals(mimeType)) {
+                final ContentValues phoneValues = new ContentValues();
+                final String number = values.getAsString(Phone.NUMBER);
+                phoneValues.put(PhoneLookupColumns.NORMALIZED_NUMBER,
+                        PhoneNumberUtils.getStrippedReversed(number));
+                phoneValues.put(PhoneLookupColumns.DATA_ID, id);
+                phoneValues.put(PhoneLookupColumns.CONTACT_ID, contactId);
+                db.insert(Tables.PHONE_LOOKUP, null, phoneValues);
+            }
+
+            mContactAggregator.markContactForAggregation(contactId);
+
+            db.setTransactionSuccessful();
+            success = true;
+        } finally {
+            db.endTransaction();
+        }
+
+        if (success) {
+            mContactAggregator.schedule();
+        }
+
+        return id;
+    }
+
+    /**
+     * 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.
+     */
+    private void parseStructuredName(ContentValues values) {
+        final String fullName = values.getAsString(StructuredName.DISPLAY_NAME);
+        if (TextUtils.isEmpty(fullName)
+                || !TextUtils.isEmpty(values.getAsString(StructuredName.PREFIX))
+                || !TextUtils.isEmpty(values.getAsString(StructuredName.GIVEN_NAME))
+                || !TextUtils.isEmpty(values.getAsString(StructuredName.MIDDLE_NAME))
+                || !TextUtils.isEmpty(values.getAsString(StructuredName.FAMILY_NAME))
+                || !TextUtils.isEmpty(values.getAsString(StructuredName.SUFFIX))) {
+            return;
+        }
+
+        NameSplitter.Name name = new NameSplitter.Name();
+        mNameSplitter.split(name, fullName);
+
+        values.put(StructuredName.PREFIX, name.getPrefix());
+        values.put(StructuredName.GIVEN_NAME, name.getGivenNames());
+        values.put(StructuredName.MIDDLE_NAME, name.getMiddleName());
+        values.put(StructuredName.FAMILY_NAME, name.getFamilyName());
+        values.put(StructuredName.SUFFIX, name.getSuffix());
+    }
+
+    /**
+     * 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) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        final String handle = values.getAsString(Presence.IM_HANDLE);
+        final String protocol = values.getAsString(Presence.IM_PROTOCOL);
+        if (TextUtils.isEmpty(handle) || TextUtils.isEmpty(protocol)) {
+            throw new IllegalArgumentException("IM_PROTOCOL and IM_HANDLE are required");
+        }
+
+        // TODO: generalize to allow other providers to match against email
+        boolean matchEmail = GTALK_PROTOCOL_STRING.equals(protocol);
+
+        String selection;
+        String[] selectionArgs;
+        if (matchEmail) {
+            selection = "(" + Clauses.WHERE_IM_MATCHES + ") OR (" + Clauses.WHERE_EMAIL_MATCHES + ")";
+            selectionArgs = new String[] { protocol, handle, handle };
+        } else {
+            selection = Clauses.WHERE_IM_MATCHES;
+            selectionArgs = new String[] { protocol, handle };
+        }
+
+        long dataId = -1;
+        long aggId = -1;
+        Cursor cursor = null;
+        try {
+            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(Projections.COL_DATA_ID);
+                aggId = cursor.getLong(Projections.COL_AGGREGATE_ID);
+            } else {
+                // No contact found, return a null URI
+                return -1;
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+
+        values.put(Presence.DATA_ID, dataId);
+        values.put(Presence.AGGREGATE_ID, aggId);
+
+        // Insert the presence update
+        long presenceId = db.replace(Tables.PRESENCE, null, values);
+        return presenceId;
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case AGGREGATES_ID: {
+                long aggregateId = ContentUris.parseId(uri);
+
+                // Remove references to the aggregate first
+                ContentValues values = new ContentValues();
+                values.putNull(Contacts.AGGREGATE_ID);
+                db.update(Tables.CONTACTS, values, Contacts.AGGREGATE_ID + "=" + aggregateId, null);
+
+                return db.delete(Tables.AGGREGATES, BaseColumns._ID + "=" + aggregateId, null);
+            }
+
+            case ACCOUNTS_ID: {
+                long accountId = ContentUris.parseId(uri);
+
+                return db.delete(Tables.ACCOUNTS, BaseColumns._ID + "=" + accountId, null);
+            }
+
+            case CONTACTS_ID: {
+                long contactId = ContentUris.parseId(uri);
+                int contactsDeleted = db.delete(Tables.CONTACTS, Contacts._ID + "=" + contactId, null);
+                int dataDeleted = db.delete(Tables.DATA, Data.CONTACT_ID + "=" + contactId, null);
+                return contactsDeleted + dataDeleted;
+            }
+
+            case DATA_ID: {
+                long dataId = ContentUris.parseId(uri);
+                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: {
+                return db.delete(Tables.PRESENCE, null, null);
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+    }
+
+    private static Account readAccountFromQueryParams(Uri uri) {
+        final String name = uri.getQueryParameter(Accounts.NAME);
+        final String type = uri.getQueryParameter(Accounts.TYPE);
+        if (TextUtils.isEmpty(name) || TextUtils.isEmpty(type)) {
+            return null;
+        }
+        return new Account(name, type);
+    }
+
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        int count = 0;
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        final int match = sUriMatcher.match(uri);
+        switch(match) {
+            case ACCOUNTS: {
+                final String accountName = uri.getQueryParameter(Accounts.NAME);
+                final String accountType = uri.getQueryParameter(Accounts.TYPE);
+                if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) {
+                    return 0;
+                }
+                final Long accountId = readAccountByName(
+                        new Account(accountName, accountType), true /* refreshIfNotFound */);
+                if (accountId == null) {
+                    return 0;
+                }
+                String selectionWithId = (Accounts._ID + " = " + accountId + " ")
+                        + (selection == null ? "" : " AND " + selection);
+                count = db.update(Tables.ACCOUNTS, values, selectionWithId, selectionArgs);
+                break;
+            }
+
+            case ACCOUNTS_ID: {
+                String selectionWithId = (Accounts._ID + " = " + ContentUris.parseId(uri) + " ")
+                        + (selection == null ? "" : " AND " + selection);
+                count = db.update(Tables.ACCOUNTS, values, selectionWithId, selectionArgs);
+                Log.i(TAG, "Selection is: " + selectionWithId);
+                break;
+            }
+
+            // TODO(emillar): We will want to disallow editing the aggregates table at some point.
+            case AGGREGATES: {
+                count = db.update(Tables.AGGREGATES, values, selection, selectionArgs);
+                break;
+            }
+
+            case AGGREGATES_ID: {
+                count = updateAggregateData(db, ContentUris.parseId(uri), values);
+                break;
+            }
+
+            case DATA_ID: {
+                boolean containsIsSuperPrimary = values.containsKey(Data.IS_SUPER_PRIMARY);
+                boolean containsIsPrimary = values.containsKey(Data.IS_PRIMARY);
+                final long id = ContentUris.parseId(uri);
+
+                // Remove primary or super primary values being set to 0. This is disallowed by the
+                // content provider.
+                if (containsIsSuperPrimary && values.getAsInteger(Data.IS_SUPER_PRIMARY) == 0) {
+                    containsIsSuperPrimary = false;
+                    values.remove(Data.IS_SUPER_PRIMARY);
+                }
+                if (containsIsPrimary && values.getAsInteger(Data.IS_PRIMARY) == 0) {
+                    containsIsPrimary = false;
+                    values.remove(Data.IS_PRIMARY);
+                }
+
+                if (containsIsSuperPrimary) {
+                    setIsSuperPrimary(id);
+                    setIsPrimary(id);
+
+                    // Now that we've taken care of setting these, remove them from "values".
+                    values.remove(Data.IS_SUPER_PRIMARY);
+                    if (containsIsPrimary) {
+                        values.remove(Data.IS_PRIMARY);
+                    }
+                } else if (containsIsPrimary) {
+                    setIsPrimary(id);
+
+                    // Now that we've taken care of setting this, remove it from "values".
+                    values.remove(Data.IS_PRIMARY);
+                }
+
+                if (values.size() > 0) {
+                    String selectionWithId = (Data._ID + " = " + ContentUris.parseId(uri) + " ")
+                            + (selection == null ? "" : " AND " + selection);
+                    count = db.update(Tables.DATA, values, selectionWithId, selectionArgs);
+                }
+                break;
+            }
+
+            case CONTACTS: {
+                count = db.update(Tables.CONTACTS, values, selection, selectionArgs);
+                break;
+            }
+
+            case CONTACTS_ID: {
+                String selectionWithId = (Contacts._ID + " = " + ContentUris.parseId(uri) + " ")
+                        + (selection == null ? "" : " AND " + selection);
+                count = db.update(Tables.CONTACTS, values, selectionWithId, selectionArgs);
+                Log.i(TAG, "Selection is: " + selectionWithId);
+                break;
+            }
+
+            case DATA: {
+                count = db.update(Tables.DATA, values, selection, selectionArgs);
+                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;
+            }
+
+            case RESTRICTION_EXCEPTIONS: {
+                // Enforce required fields
+                boolean hasFields = values.containsKey(RestrictionExceptions.PACKAGE_PROVIDER)
+                        && values.containsKey(RestrictionExceptions.PACKAGE_CLIENT)
+                        && values.containsKey(RestrictionExceptions.ALLOW_ACCESS);
+                if (!hasFields) {
+                    throw new IllegalArgumentException("PACKAGE_PROVIDER, PACKAGE_CLIENT, and"
+                            + "ALLOW_ACCESS are all required fields");
+                }
+
+                final String packageProvider = values
+                        .getAsString(RestrictionExceptions.PACKAGE_PROVIDER);
+                final boolean allowAccess = (values
+                        .getAsInteger(RestrictionExceptions.ALLOW_ACCESS) == 1);
+
+                final Context context = getContext();
+                final PackageManager pm = context.getPackageManager();
+
+                // Enforce that caller has authority over the requested package
+                // TODO: move back to Binder.getCallingUid() when we can stub-out test suite
+                final int callingUid = OpenHelper
+                        .getUidForPackageName(pm, context.getPackageName());
+                final String[] ownedPackages = pm.getPackagesForUid(callingUid);
+                if (!isContained(ownedPackages, packageProvider)) {
+                    throw new RuntimeException(
+                            "Requested PACKAGE_PROVIDER doesn't belong to calling UID.");
+                }
+
+                // Add or remove exception using exception helper
+                if (allowAccess) {
+                    mOpenHelper.addRestrictionException(context, values);
+                } else {
+                    mOpenHelper.removeRestrictionException(context, values);
+                }
+
+                break;
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        if (count > 0) {
+            getContext().getContentResolver().notifyChange(uri, null);
+        }
+        return count;
+    }
+
+    private int updateAggregateData(SQLiteDatabase db, long aggregateId, ContentValues values) {
+
+        // First update all constituent contacts
+        ContentValues optionValues = new ContentValues(3);
+        if (values.containsKey(Aggregates.CUSTOM_RINGTONE)) {
+            optionValues.put(ContactOptionsColumns.CUSTOM_RINGTONE,
+                    values.getAsString(Aggregates.CUSTOM_RINGTONE));
+        }
+        if (values.containsKey(Aggregates.SEND_TO_VOICEMAIL)) {
+            optionValues.put(ContactOptionsColumns.SEND_TO_VOICEMAIL,
+                    values.getAsBoolean(Aggregates.SEND_TO_VOICEMAIL));
+        }
+
+        // Nothing to update - just return
+        if (optionValues.size() == 0) {
+            return 0;
+        }
+
+        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(Projections.COL_CONTACT_ID);
+
+                optionValues.put(ContactOptionsColumns._ID, contactId);
+                db.replace(Tables.CONTACT_OPTIONS, null, optionValues);
+            }
+        } finally {
+            c.close();
+        }
+
+        // Now update the aggregate itself.  Ignore all supplied fields except rington and
+        // send_to_voicemail
+        optionValues.clear();
+        if (values.containsKey(Aggregates.CUSTOM_RINGTONE)) {
+            optionValues.put(Aggregates.CUSTOM_RINGTONE,
+                    values.getAsString(Aggregates.CUSTOM_RINGTONE));
+        }
+        if (values.containsKey(Aggregates.SEND_TO_VOICEMAIL)) {
+            optionValues.put(Aggregates.SEND_TO_VOICEMAIL,
+                    values.getAsBoolean(Aggregates.SEND_TO_VOICEMAIL));
+        }
+
+        return db.update(Tables.AGGREGATES, optionValues, Aggregates._ID + "=" + aggregateId, null);
+    }
+
+    private static class ContactPair {
+        final long contactId1;
+        final long contactId2;
+
+        /**
+         * Constructor that ensures that this.contactId1 &lt; this.contactId2
+         */
+        public ContactPair(long contactId1, long contactId2) {
+            if (contactId1 < contactId2) {
+                this.contactId1 = contactId1;
+                this.contactId2 = contactId2;
+            } else {
+                this.contactId2 = contactId1;
+                this.contactId1 = contactId2;
+            }
+        }
+    }
+
+    private int updateAggregationException(SQLiteDatabase db, ContentValues values) {
+        int exceptionType = values.getAsInteger(AggregationExceptions.TYPE);
+        long aggregateId = values.getAsInteger(AggregationExceptions.AGGREGATE_ID);
+        long contactId = values.getAsInteger(AggregationExceptions.CONTACT_ID);
+
+        // 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, Projections.PROJ_CONTACTS,
+                Contacts.AGGREGATE_ID + "=" + aggregateId,
+                null, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                long aggregatedContactId = c.getLong(Projections.COL_CONTACT_ID);
+                if (aggregatedContactId != contactId) {
+                    pairs.add(new ContactPair(aggregatedContactId, contactId));
+                }
+            }
+        } finally {
+            c.close();
+        }
+
+        // Now we iterate through all contact pairs to see if we need to insert/delete/update
+        // the corresponding exception
+        ContentValues exceptionValues = new ContentValues(3);
+        exceptionValues.put(AggregationExceptions.TYPE, exceptionType);
+        for (ContactPair pair : pairs) {
+            final String whereClause =
+                    AggregationExceptionColumns.CONTACT_ID1 + "=" + pair.contactId1 + " AND "
+                    + AggregationExceptionColumns.CONTACT_ID2 + "=" + pair.contactId2;
+            if (exceptionType == AggregationExceptions.TYPE_AUTOMATIC) {
+                db.delete(Tables.AGGREGATION_EXCEPTIONS, whereClause, null);
+            } else {
+                exceptionValues.put(AggregationExceptionColumns.CONTACT_ID1, pair.contactId1);
+                exceptionValues.put(AggregationExceptionColumns.CONTACT_ID2, pair.contactId2);
+                db.replace(Tables.AGGREGATION_EXCEPTIONS, AggregationExceptions._ID,
+                        exceptionValues);
+            }
+        }
+
+        mContactAggregator.markContactForAggregation(contactId);
+        mContactAggregator.aggregateContact(contactId);
+        if (exceptionType == AggregationExceptions.TYPE_AUTOMATIC
+                || exceptionType == AggregationExceptions.TYPE_KEEP_OUT) {
+            mContactAggregator.updateAggregateData(aggregateId);
+        }
+
+        // The return value is fake - we just confirm that we made a change, not count actual
+        // rows changed.
+        return 1;
+    }
+
+    /**
+     * Test if a {@link String} value appears in the given list.
+     */
+    private boolean isContained(String[] array, String value) {
+        if (array != null) {
+            for (String test : array) {
+                if (value.equals(test)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Test if a {@link String} value appears in the given list, and add to the
+     * array if the value doesn't already appear.
+     */
+    private String[] assertContained(String[] array, String value) {
+        if (array == null) {
+            array = new String[] {value};
+        } else if (!isContained(array, value)) {
+            String[] newArray = new String[array.length + 1];
+            System.arraycopy(array, 0, newArray, 0, array.length);
+            newArray[array.length] = value;
+            array = newArray;
+        }
+        return array;
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        String groupBy = null;
+        String limit = null;
+        String aggregateIdColName = Tables.AGGREGATES + "." + Aggregates._ID;
+
+        // TODO: Consider writing a test case for RestrictionExceptions when you
+        // write a new query() block to make sure it protects restricted data.
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case ACCOUNTS: {
+                qb.setTables(Tables.ACCOUNTS);
+                qb.setProjectionMap(sAccountsProjectionMap);
+                break;
+            }
+
+            case ACCOUNTS_ID: {
+                qb.setTables(Tables.ACCOUNTS);
+                qb.setProjectionMap(sAccountsProjectionMap);
+                qb.appendWhere(BaseColumns._ID + " = " + ContentUris.parseId(uri));
+                break;
+            }
+
+            case AGGREGATES: {
+                qb.setTables(Tables.AGGREGATES);
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sAggregatesProjectionMap);
+                qb.setProjectionMap(sAggregatesProjectionMap);
+                break;
+            }
+
+            case AGGREGATES_ID: {
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.AGGREGATES);
+                qb.appendWhere(AggregatesColumns.CONCRETE_ID + "=" + aggId + " AND ");
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sAggregatesProjectionMap);
+                qb.setProjectionMap(sAggregatesProjectionMap);
+                break;
+            }
+
+            case AGGREGATES_SUMMARY: {
+                // TODO: join into social status tables
+                qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sAggregatesSummaryProjectionMap);
+                projection = assertContained(projection, Aggregates.PRIMARY_PHONE_ID);
+                qb.setProjectionMap(sAggregatesSummaryProjectionMap);
+                groupBy = aggregateIdColName;
+                break;
+            }
+
+            case AGGREGATES_SUMMARY_ID: {
+                // TODO: join into social status tables
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.appendWhere(AggregatesColumns.CONCRETE_ID + "=" + aggId + " AND ");
+                applyAggregateRestrictionExceptions(qb);
+                applyAggregatePrimaryRestrictionExceptions(sAggregatesSummaryProjectionMap);
+                projection = assertContained(projection, Aggregates.PRIMARY_PHONE_ID);
+                qb.setProjectionMap(sAggregatesSummaryProjectionMap);
+                groupBy = aggregateIdColName;
+                break;
+            }
+
+            case AGGREGATES_SUMMARY_FILTER: {
+                // TODO: filter query based on callingUid
+                qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sAggregatesSummaryProjectionMap);
+                if (uri.getPathSegments().size() > 2) {
+                    qb.appendWhere(buildAggregateLookupWhereClause(uri.getLastPathSegment()));
+                }
+                groupBy = aggregateIdColName;
+                break;
+            }
+
+            case AGGREGATES_SUMMARY_STREQUENT_FILTER:
+            case AGGREGATES_SUMMARY_STREQUENT: {
+                // Build the first query for starred
+                qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sAggregatesSummaryProjectionMap);
+                if (match == AGGREGATES_SUMMARY_STREQUENT_FILTER
+                        && uri.getPathSegments().size() > 3) {
+                    qb.appendWhere(buildAggregateLookupWhereClause(uri.getLastPathSegment()));
+                }
+                final String starredQuery = qb.buildQuery(projection, Aggregates.STARRED + "=1",
+                        null, aggregateIdColName, null, null,
+                        null /* limit */);
+
+                // Build the second query for frequent
+                qb = new SQLiteQueryBuilder();
+                qb.setTables(Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+                qb.setProjectionMap(sAggregatesSummaryProjectionMap);
+                if (match == AGGREGATES_SUMMARY_STREQUENT_FILTER
+                        && uri.getPathSegments().size() > 3) {
+                    qb.appendWhere(buildAggregateLookupWhereClause(uri.getLastPathSegment()));
+                }
+                final String frequentQuery = qb.buildQuery(projection,
+                        Aggregates.TIMES_CONTACTED + " > 0 AND (" + Aggregates.STARRED
+                        + " = 0 OR " + Aggregates.STARRED + " IS NULL)",
+                        null, aggregateIdColName, null, null, null);
+
+                // Put them together
+                final String query = qb.buildUnionQuery(new String[] {starredQuery, frequentQuery},
+                        STREQUENT_ORDER_BY, STREQUENT_LIMIT);
+                Cursor c = db.rawQueryWithFactory(null, query, null,
+                        Tables.AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE);
+
+                if ((c != null) && !isTemporary()) {
+                    c.setNotificationUri(getContext().getContentResolver(),
+                            ContactsContract.AUTHORITY_URI);
+                }
+                return c;
+            }
+
+            case AGGREGATES_DATA: {
+                long aggId = Long.parseLong(uri.getPathSegments().get(1));
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sDataContactsAggregateProjectionMap);
+                qb.appendWhere(Contacts.AGGREGATE_ID + "=" + aggId + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case PHONES_FILTER: {
+                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) {
+                    qb.appendWhere(" AND " + buildAggregateLookupWhereClause(
+                            uri.getLastPathSegment()));
+                }
+                break;
+            }
+
+            case PHONES: {
+                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_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sDataContactsAggregateProjectionMap);
+                qb.appendWhere(Data.MIMETYPE + " = \"" + Postal.CONTENT_ITEM_TYPE + "\"");
+                break;
+            }
+
+            case CONTACTS: {
+                qb.setTables(Tables.CONTACTS_JOIN_PACKAGES_ACCOUNTS);
+                qb.setProjectionMap(sContactsProjectionMap);
+                applyContactsRestrictionExceptions(qb);
+                break;
+            }
+
+            case CONTACTS_ID: {
+                long contactId = ContentUris.parseId(uri);
+                qb.setTables(Tables.CONTACTS_JOIN_PACKAGES_ACCOUNTS);
+                qb.setProjectionMap(sContactsProjectionMap);
+                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_MIMETYPES_CONTACTS_PACKAGES);
+                qb.setProjectionMap(sDataContactsProjectionMap);
+                qb.appendWhere(Data.CONTACT_ID + "=" + contactId + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case CONTACTS_FILTER_EMAIL: {
+                // TODO: filter query based on callingUid
+                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 + "=");
+                qb.appendWhereEscapeString(uri.getPathSegments().get(2));
+                break;
+            }
+
+            case DATA: {
+                final String accountName = uri.getQueryParameter(Accounts.NAME);
+                final String accountType = uri.getQueryParameter(Accounts.TYPE);
+                if (!TextUtils.isEmpty(accountName)) {
+                    Account account = new Account(accountName, accountType);
+                    Long accountId = readAccountByName(account, true /* refreshIfNotFound */);
+                    if (accountId == null) {
+                        // use -1 as the account to ensure that no rows are returned
+                        accountId = (long) -1;
+                    }
+                    qb.appendWhere(Contacts.ACCOUNTS_ID + "=" + accountId + " AND ");
+                }
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
+                qb.setProjectionMap(sDataProjectionMap);
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case DATA_ID: {
+                qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
+                qb.setProjectionMap(sDataProjectionMap);
+                qb.appendWhere(DataColumns.CONCRETE_ID + "=" + ContentUris.parseId(uri) + " AND ");
+                applyDataRestrictionExceptions(qb);
+                break;
+            }
+
+            case PHONE_LOOKUP: {
+                // TODO: filter query based on callingUid
+                if (TextUtils.isEmpty(sortOrder)) {
+                    // Default the sort order to something reasonable so we get consistent
+                    // results when callers don't request an ordering
+                    sortOrder = Data.CONTACT_ID;
+                }
+
+                final String number = uri.getLastPathSegment();
+                OpenHelper.buildPhoneLookupQuery(qb, number);
+                qb.setProjectionMap(sDataContactsProjectionMap);
+                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);
+                break;
+            }
+
+            case AGGREGATION_SUGGESTIONS: {
+                long aggregateId = Long.parseLong(uri.getPathSegments().get(1));
+                final String maxSuggestionsParam =
+                        uri.getQueryParameter(AggregationSuggestions.MAX_SUGGESTIONS);
+
+                final int maxSuggestions;
+                if (maxSuggestionsParam != null) {
+                    maxSuggestions = Integer.parseInt(maxSuggestionsParam);
+                } else {
+                    maxSuggestions = DEFAULT_MAX_SUGGESTIONS;
+                }
+
+                return mContactAggregator.queryAggregationSuggestions(aggregateId, projection,
+                        sAggregatesProjectionMap, maxSuggestions);
+            }
+
+            case RESTRICTION_EXCEPTIONS: {
+                qb.setTables(Tables.RESTRICTION_EXCEPTIONS);
+                qb.setProjectionMap(sRestrictionExceptionsProjectionMap);
+                break;
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        // Perform the query and set the notification uri
+        final Cursor c = qb.query(db, projection, selection, selectionArgs,
+                groupBy, null, sortOrder, limit);
+        if (c != null) {
+            c.setNotificationUri(getContext().getContentResolver(), ContactsContract.AUTHORITY_URI);
+        }
+        return c;
+    }
+
+    /**
+     * Restrict selection of {@link Aggregates} to only public ones, or those
+     * the caller has been granted a {@link RestrictionExceptions} to.
+     */
+    private void applyAggregateRestrictionExceptions(SQLiteQueryBuilder qb) {
+        final int clientUid = OpenHelper.getUidForPackageName(getContext().getPackageManager(),
+                getContext().getPackageName());
+
+        qb.appendWhere("(" + AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID + " IS NULL");
+        final String exceptionClause = mOpenHelper.getRestrictionExceptionClause(clientUid,
+                AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID);
+        if (exceptionClause != null) {
+            qb.appendWhere(" OR (" + exceptionClause + ")");
+        }
+        qb.appendWhere(")");
+    }
+
+    /**
+     * Find any exceptions that have been granted to the calling process, and
+     * add projections to correctly select {@link Aggregates#PRIMARY_PHONE_ID}
+     * and {@link Aggregates#PRIMARY_EMAIL_ID}.
+     */
+    private void applyAggregatePrimaryRestrictionExceptions(HashMap<String, String> projection) {
+        // TODO: move back to Binder.getCallingUid() when we can stub-out test suite
+        final int clientUid = OpenHelper.getUidForPackageName(getContext().getPackageManager(),
+                getContext().getPackageName());
+
+        final String projectionPhone = "(CASE WHEN "
+                + mOpenHelper.getRestrictionExceptionClause(clientUid,
+                        AggregatesColumns.OPTIMAL_PRIMARY_PHONE_PACKAGE_ID) + " THEN "
+                + AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID + " ELSE "
+                + AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID + " END) AS "
+                + Aggregates.PRIMARY_PHONE_ID;
+        projection.remove(Aggregates.PRIMARY_PHONE_ID);
+        projection.put(Aggregates.PRIMARY_PHONE_ID, projectionPhone);
+
+        final String projectionEmail = "(CASE WHEN "
+            + mOpenHelper.getRestrictionExceptionClause(clientUid,
+                    AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID) + " THEN "
+            + AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID + " ELSE "
+            + AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID + " END) AS "
+            + Aggregates.PRIMARY_EMAIL_ID;
+        projection.remove(Aggregates.PRIMARY_EMAIL_ID);
+        projection.put(Aggregates.PRIMARY_EMAIL_ID, projectionEmail);
+    }
+
+    /**
+     * Find any exceptions that have been granted to the
+     * {@link Binder#getCallingUid()}, and add a limiting clause to the given
+     * {@link SQLiteQueryBuilder} to hide restricted data.
+     */
+    private void applyContactsRestrictionExceptions(SQLiteQueryBuilder qb) {
+        // TODO: move back to Binder.getCallingUid() when we can stub-out test suite
+        final int clientUid = OpenHelper.getUidForPackageName(getContext().getPackageManager(),
+                getContext().getPackageName());
+
+        qb.appendWhere("(" + Contacts.IS_RESTRICTED + "=0");
+        final String exceptionClause = mOpenHelper.getRestrictionExceptionClause(clientUid,
+                ContactsColumns.PACKAGE_ID);
+        if (exceptionClause != null) {
+            qb.appendWhere(" OR (" + exceptionClause + ")");
+        }
+        qb.appendWhere(")");
+    }
+
+    /**
+     * Find any exceptions that have been granted to the
+     * {@link Binder#getCallingUid()}, and add a limiting clause to the given
+     * {@link SQLiteQueryBuilder} to hide restricted data.
+     */
+    private void applyDataRestrictionExceptions(SQLiteQueryBuilder qb) {
+        applyContactsRestrictionExceptions(qb);
+    }
+
+    /**
+     * An implementation of EntityIterator that joins the contacts and data tables
+     * and consumes all the data rows for a contact in order to build the Entity for a contact.
+     */
+    private static class ContactsEntityIterator implements EntityIterator {
+        private final Cursor mEntityCursor;
+        private volatile boolean mIsClosed;
+        private final Account mAccount;
+
+        private static final String[] DATA_KEYS = new String[]{
+                "data1",
+                "data2",
+                "data3",
+                "data4",
+                "data5",
+                "data6",
+                "data7",
+                "data8",
+                "data9",
+                "data10"};
+
+        private static final String[] PROJECTION = new String[]{
+            Contacts.ACCOUNTS_ID,
+            Contacts.SOURCE_ID,
+            Contacts.VERSION,
+            Contacts.DIRTY,
+            Contacts.Data._ID,
+            Contacts.Data.MIMETYPE,
+            Contacts.Data.DATA1,
+            Contacts.Data.DATA2,
+            Contacts.Data.DATA3,
+            Contacts.Data.DATA4,
+            Contacts.Data.DATA5,
+            Contacts.Data.DATA6,
+            Contacts.Data.DATA7,
+            Contacts.Data.DATA8,
+            Contacts.Data.DATA9,
+            Contacts.Data.DATA10,
+            Contacts.Data.CONTACT_ID,
+            Contacts.Data.IS_PRIMARY,
+            Contacts.Data.DATA_VERSION};
+
+        private static final int COLUMN_SOURCE_ID = 1;
+        private static final int COLUMN_VERSION = 2;
+        private static final int COLUMN_DIRTY = 3;
+        private static final int COLUMN_DATA_ID = 4;
+        private static final int COLUMN_MIMETYPE = 5;
+        private static final int COLUMN_DATA1 = 6;
+        private static final int COLUMN_CONTACT_ID = 16;
+        private static final int COLUMN_IS_PRIMARY = 17;
+        private static final int COLUMN_DATA_VERSION = 18;
+
+        public ContactsEntityIterator(ContactsProvider2 provider, String contactsIdString, Uri uri,
+                String selection, String[] selectionArgs, String sortOrder) {
+            mIsClosed = false;
+
+            final String accountName = uri.getQueryParameter(Accounts.NAME);
+            final String accountType = uri.getQueryParameter(Accounts.TYPE);
+            if (TextUtils.isEmpty(accountName) || TextUtils.isEmpty(accountType)) {
+                throw new IllegalArgumentException("the account name and type must be "
+                        + "specified in the query params of the uri");
+            }
+            mAccount = new Account(accountName, accountType);
+            final Long accountId = provider.readAccountByName(mAccount,
+                    true /* refreshIfNotFound */);
+            if (accountId == null) {
+                throw new IllegalArgumentException("the specified account does not exist");
+            }
+
+            final String updatedSortOrder = (sortOrder == null)
+                    ? Contacts.Data.CONTACT_ID
+                    : (Contacts.Data.CONTACT_ID + "," + sortOrder);
+
+            final SQLiteDatabase db = provider.mOpenHelper.getReadableDatabase();
+            final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+            qb.setTables(Tables.DATA_JOIN_MIMETYPES_CONTACTS_PACKAGES);
+            qb.setProjectionMap(sDataContactsAccountsProjectionMap);
+            if (contactsIdString != null) {
+                qb.appendWhere(Data.CONTACT_ID + "=" + contactsIdString);
+            }
+            qb.appendWhere(Contacts.ACCOUNTS_ID + "=" + accountId);
+            mEntityCursor = qb.query(db, PROJECTION, selection, selectionArgs,
+                    null, null, updatedSortOrder);
+            mEntityCursor.moveToFirst();
+        }
+
+        public void close() {
+            if (mIsClosed) {
+                throw new IllegalStateException("closing when already closed");
+            }
+            mIsClosed = true;
+            mEntityCursor.close();
+        }
+
+        public boolean hasNext() throws RemoteException {
+            if (mIsClosed) {
+                throw new IllegalStateException("calling hasNext() when the iterator is closed");
+            }
+
+            return !mEntityCursor.isAfterLast();
+        }
+
+        public Entity next() throws RemoteException {
+            if (mIsClosed) {
+                throw new IllegalStateException("calling next() when the iterator is closed");
+            }
+            if (!hasNext()) {
+                throw new IllegalStateException("you may only call next() if hasNext() is true");
+            }
+
+            final SQLiteCursor c = (SQLiteCursor) mEntityCursor;
+
+            final long contactId = c.getLong(COLUMN_CONTACT_ID);
+
+            // we expect the cursor is already at the row we need to read from
+            ContentValues contactValues = new ContentValues();
+            contactValues.put(Accounts.NAME, mAccount.mName);
+            contactValues.put(Accounts.TYPE, mAccount.mType);
+            contactValues.put(Contacts._ID, contactId);
+            contactValues.put(Contacts.DIRTY, c.getLong(COLUMN_DIRTY));
+            contactValues.put(Contacts.VERSION, c.getLong(COLUMN_VERSION));
+            contactValues.put(Contacts.SOURCE_ID, c.getString(COLUMN_SOURCE_ID));
+            Entity contact = new Entity(contactValues);
+
+            // read data rows until the contact id changes
+            do {
+                if (contactId != c.getLong(COLUMN_CONTACT_ID)) {
+                    break;
+                }
+                // add the data to to the contact
+                ContentValues dataValues = new ContentValues();
+                dataValues.put(Contacts.Data._ID, c.getString(COLUMN_DATA_ID));
+                dataValues.put(Contacts.Data.MIMETYPE, c.getString(COLUMN_MIMETYPE));
+                dataValues.put(Contacts.Data.IS_PRIMARY, c.getString(COLUMN_IS_PRIMARY));
+                dataValues.put(Contacts.Data.DATA_VERSION, c.getLong(COLUMN_DATA_VERSION));
+                for (int i = 0; i < 10; i++) {
+                    final int columnIndex = i + COLUMN_DATA1;
+                    String key = DATA_KEYS[i];
+                    if (c.isNull(columnIndex)) {
+                        // don't put anything
+                    } else if (c.isLong(columnIndex)) {
+                        dataValues.put(key, c.getLong(columnIndex));
+                    } else if (c.isFloat(columnIndex)) {
+                        dataValues.put(key, c.getFloat(columnIndex));
+                    } else if (c.isString(columnIndex)) {
+                        dataValues.put(key, c.getString(columnIndex));
+                    } else if (c.isBlob(columnIndex)) {
+                        dataValues.put(key, c.getBlob(columnIndex));
+                    }
+                }
+                contact.addSubValue(Data.CONTENT_URI, dataValues);
+            } while (mEntityCursor.moveToNext());
+
+            return contact;
+        }
+    }
+
+    @Override
+    public EntityIterator queryEntities(Uri uri, String selection, String[] selectionArgs,
+            String sortOrder) {
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case CONTACTS:
+            case CONTACTS_ID:
+                String contactsIdString = null;
+                if (match == CONTACTS_ID) {
+                    contactsIdString = uri.getPathSegments().get(1);
+                }
+
+                return new ContactsEntityIterator(this, contactsIdString,
+                        uri, selection, selectionArgs, sortOrder);
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case ACCOUNTS: return Accounts.CONTENT_TYPE;
+            case ACCOUNTS_ID: return Accounts.CONTENT_ITEM_TYPE;
+            case AGGREGATES: return Aggregates.CONTENT_TYPE;
+            case AGGREGATES_ID: return Aggregates.CONTENT_ITEM_TYPE;
+            case CONTACTS: return Contacts.CONTENT_TYPE;
+            case CONTACTS_ID: return Contacts.CONTENT_ITEM_TYPE;
+            case DATA_ID:
+                final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+                long dataId = ContentUris.parseId(uri);
+                return mOpenHelper.getDataMimeType(dataId);
+            case AGGREGATION_EXCEPTIONS: return AggregationExceptions.CONTENT_TYPE;
+            case AGGREGATION_EXCEPTION_ID: return AggregationExceptions.CONTENT_ITEM_TYPE;
+            case AGGREGATION_SUGGESTIONS: return Aggregates.CONTENT_TYPE;
+        }
+        throw new UnsupportedOperationException("Unknown uri: " + uri);
+    }
+
+    @Override
+    public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
+            throws OperationApplicationException {
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        db.beginTransaction();
+        try {
+            ContentProviderResult[] results = super.applyBatch(operations);
+            db.setTransactionSuccessful();
+            return results;
+        } finally {
+            db.endTransaction();
+        }
+    }
+
+    /*
+     * Sets the given dataId record in the "data" table to primary, and resets all data records of
+     * the same mimetype and under the same contact to not be primary.
+     *
+     * @param dataId the id of the data record to be set to primary.
+     */
+    private void setIsPrimary(long dataId) {
+        mSetPrimaryStatement.bindLong(1, dataId);
+        mSetPrimaryStatement.bindLong(2, dataId);
+        mSetPrimaryStatement.bindLong(3, dataId);
+        mSetPrimaryStatement.execute();
+    }
+
+    /*
+     * Sets the given dataId record in the "data" table to "super primary", and resets all data
+     * records of the same mimetype and under the same aggregate to not be "super primary".
+     *
+     * @param dataId the id of the data record to be set to primary.
+     */
+    private void setIsSuperPrimary(long dataId) {
+        mSetSuperPrimaryStatement.bindLong(1, dataId);
+        mSetSuperPrimaryStatement.bindLong(2, dataId);
+        mSetSuperPrimaryStatement.bindLong(3, dataId);
+        mSetSuperPrimaryStatement.execute();
+
+        // Find the parent aggregate and package for this new primary
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        long aggId = -1;
+        long packageId = -1;
+        boolean isRestricted = false;
+        String mimeType = null;
+
+        Cursor cursor = null;
+        try {
+            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(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) {
+                cursor.close();
+            }
+        }
+
+        // Bypass aggregate update if no parent found, or if we don't keep track
+        // of super-primary for this mimetype.
+        if (aggId == -1) {
+            return;
+        }
+
+        boolean isPhone = CommonDataKinds.Phone.CONTENT_ITEM_TYPE.equals(mimeType);
+        boolean isEmail = CommonDataKinds.Email.CONTENT_ITEM_TYPE.equals(mimeType);
+
+        // Record this value as the new primary for the parent aggregate
+        final ContentValues values = new ContentValues();
+        if (isPhone) {
+            values.put(AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID, dataId);
+            values.put(AggregatesColumns.OPTIMAL_PRIMARY_PHONE_PACKAGE_ID, packageId);
+        } else if (isEmail) {
+            values.put(AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID, dataId);
+            values.put(AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID, packageId);
+        }
+
+        // If this data is unrestricted, then also set as fallback
+        if (!isRestricted && isPhone) {
+            values.put(AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID, dataId);
+        } else if (!isRestricted && isEmail) {
+            values.put(AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID, dataId);
+        }
+
+        // Push update into aggregates table, if needed
+        if (values.size() > 0) {
+            db.update(Tables.AGGREGATES, values, Aggregates._ID + "=" + aggId, null);
+        }
+
+    }
+
+    private String buildAggregateLookupWhereClause(String filterParam) {
+        StringBuilder filter = new StringBuilder();
+        filter.append(Tables.AGGREGATES);
+        filter.append(".");
+        filter.append(Aggregates._ID);
+        filter.append(" IN (SELECT ");
+        filter.append(Contacts.AGGREGATE_ID);
+        filter.append(" FROM ");
+        filter.append(Tables.CONTACTS);
+        filter.append(" WHERE ");
+        filter.append(Contacts._ID);
+        filter.append(" IN (SELECT  contact_id FROM name_lookup WHERE normalized_name GLOB '");
+        // NOTE: Query parameters won't work here since the SQL compiler
+        // needs to parse the actual string to know that it can use the
+        // index to do a prefix scan.
+        filter.append(NameNormalizer.normalize(filterParam) + "*");
+        filter.append("'))");
+        return filter.toString();
+    }
+
+}
diff --git a/src/com/android/providers/contacts/Hex.java b/src/com/android/providers/contacts/Hex.java
new file mode 100644
index 0000000..991f095
--- /dev/null
+++ b/src/com/android/providers/contacts/Hex.java
@@ -0,0 +1,121 @@
+/*
+ * 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.contacts;
+
+/**
+ * Basic hex operations: from byte array to string and vice versa.
+ *
+ * TODO: move to the framework and consider implementing as native code.
+ */
+public class Hex {
+
+    private static final char[] HEX_DIGITS = new char[]{
+            '0', '1', '2', '3', '4', '5', '6', '7',
+            '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+    };
+
+    private static final char[] FIRST_CHAR = new char[256];
+    private static final char[] SECOND_CHAR = new char[256];
+    static {
+        for (int i = 0; i < 256; i++) {
+            FIRST_CHAR[i] = HEX_DIGITS[(i >> 4) & 0xF];
+            SECOND_CHAR[i] = HEX_DIGITS[i & 0xF];
+        }
+    }
+
+    private static final byte[] DIGITS = new byte['f'+1];
+    static {
+        for (int i = 0; i <= 'F'; i++) {
+            DIGITS[i] = -1;
+        }
+        for (byte i = 0; i < 10; i++) {
+            DIGITS['0' + i] = i;
+        }
+        for (byte i = 0; i < 6; i++) {
+            DIGITS['A' + i] = (byte)(10 + i);
+            DIGITS['a' + i] = (byte)(10 + i);
+        }
+    }
+
+    /**
+     * Quickly converts a byte array to a hexadecimal string representation.
+     *
+     * @param array byte array, possibly zero-terminated.
+     */
+    public static String encodeHex(byte[] array, boolean zeroTerminated) {
+        char[] cArray = new char[array.length * 2];
+
+        int j = 0;
+        for (int i = 0; i < array.length; i++) {
+            int index = array[i] & 0xFF;
+            if (index == 0 && zeroTerminated) {
+                break;
+            }
+
+            cArray[j++] = FIRST_CHAR[index];
+            cArray[j++] = SECOND_CHAR[index];
+        }
+
+        return new String(cArray, 0, j);
+    }
+
+    /**
+     * Quickly converts a hexadecimal string to a byte array.
+     */
+    public static byte[] decodeHex(String hexString) {
+        int length = hexString.length();
+
+        if ((length & 0x01) != 0) {
+            throw new IllegalArgumentException("Odd number of characters.");
+        }
+
+        boolean badHex = false;
+        byte[] out = new byte[length >> 1];
+        for (int i = 0, j = 0; j < length; i++) {
+            int c1 = hexString.charAt(j++);
+            if (c1 > 'f') {
+                badHex = true;
+                break;
+            }
+
+            final byte d1 = DIGITS[c1];
+            if (d1 == -1) {
+                badHex = true;
+                break;
+            }
+
+            int c2 = hexString.charAt(j++);
+            if (c2 > 'f') {
+                badHex = true;
+                break;
+            }
+
+            final byte d2 = DIGITS[c2];
+            if (d2 == -1) {
+                badHex = true;
+                break;
+            }
+
+            out[i] = (byte) (d1 << 4 | d2);
+        }
+
+        if (badHex) {
+            throw new IllegalArgumentException("Invalid hexadecimal digit: " + hexString);
+        }
+
+        return out;
+    }
+}
diff --git a/src/com/android/providers/contacts/JaroWinklerDistance.java b/src/com/android/providers/contacts/JaroWinklerDistance.java
new file mode 100644
index 0000000..730059d
--- /dev/null
+++ b/src/com/android/providers/contacts/JaroWinklerDistance.java
@@ -0,0 +1,140 @@
+/*
+ * 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.contacts;
+
+import java.util.Arrays;
+
+/**
+ * A string distance calculator, particularly suited for name matching.
+ * <p>
+ * A detailed discussion of the topic of record linkage in general and name matching
+ * in particular can be found in this article:
+ * <blockquote>
+ * Winkler, W. E. (2006). "Overview of Record Linkage and Current Research Directions".
+ * Research Report Series, RRS.
+ * </blockquote>
+ */
+public class JaroWinklerDistance {
+
+    private static final float WINKLER_BONUS_THRESHOLD = 0.7f;
+
+    private final int mMaxLength;
+    private final boolean[] mMatchFlags1;
+    private final boolean[] mMatchFlags2;
+
+    /**
+     * Constructor.
+     *
+     * @param maxLength byte arrays are truncate if longer than this number
+     */
+    public JaroWinklerDistance(int maxLength) {
+        mMaxLength = maxLength;
+        mMatchFlags1 = new boolean[maxLength];
+        mMatchFlags2 = new boolean[maxLength];
+    }
+
+    /**
+     * Computes a string distance between two normalized strings passed as byte arrays.
+     */
+    public float getDistance(byte bytes1[], byte bytes2[]) {
+        byte[] array1, array2;
+
+        if (bytes1.length > bytes2.length) {
+            array2 = bytes1;
+            array1 = bytes2;
+        } else {
+            array2 = bytes2;
+            array1 = bytes1;
+        }
+
+        int length1 = array1.length;
+        if (length1 > mMaxLength) {
+            length1 = mMaxLength;
+        }
+
+        int length2 = array2.length;
+        if (length2 > mMaxLength) {
+            length2 = mMaxLength;
+        }
+
+        Arrays.fill(mMatchFlags1, 0, length1, false);
+        Arrays.fill(mMatchFlags2, 0, length2, false);
+
+        int range = length2 / 2 - 1;
+        if (range < 0) {
+            range = 0;
+        }
+
+        int matches = 0;
+        for (int i = 0; i < length1; i++) {
+            byte c1 = array1[i];
+
+            int from = i - range;
+            if (from < 0) {
+                from = 0;
+            }
+
+            int to = i + range + 1;
+            if (to > length2) {
+                to = length2;
+            }
+
+            for (int j = from; j < to; j++) {
+                if (!mMatchFlags2[j] && c1 == array2[j]) {
+                    mMatchFlags1[i] = mMatchFlags2[j] = true;
+                    matches++;
+                    break;
+                }
+            }
+        }
+
+        if (matches == 0) {
+            return 0f;
+        }
+
+        int transpositions = 0;
+        int j = 0;
+        for (int i = 0; i < length1; i++) {
+            if (mMatchFlags1[i]) {
+                while (!mMatchFlags2[j]) {
+                    j++;
+                }
+                if (array1[i] != array2[j]) {
+                    transpositions++;
+                }
+                j++;
+            }
+        }
+
+        float m = matches;
+        float jaro = ((m / length1 + m / length2 + (m - (transpositions / 2)) / m)) / 3;
+
+        if (jaro < WINKLER_BONUS_THRESHOLD) {
+            return jaro;
+        }
+
+        // Add Winkler bonus
+        int prefix = 0;
+        for (int i = 0; i < length1; i++) {
+            if (bytes1[i] != bytes2[i]) {
+                break;
+            }
+            prefix++;
+        }
+
+        return jaro + Math.min(0.1f, 1f / length2) * prefix * (1 - jaro);
+    }
+}
diff --git a/src/com/android/providers/contacts/NameNormalizer.java b/src/com/android/providers/contacts/NameNormalizer.java
new file mode 100644
index 0000000..eca40fc
--- /dev/null
+++ b/src/com/android/providers/contacts/NameNormalizer.java
@@ -0,0 +1,83 @@
+/*
+ * 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.contacts;
+
+import com.ibm.icu4jni.text.CollationAttribute;
+import com.ibm.icu4jni.text.Collator;
+import com.ibm.icu4jni.text.RuleBasedCollator;
+
+/**
+ * Converts a name to a normalized form by removing all non-letter characters and normalizing
+ * UNICODE according to http://unicode.org/unicode/reports/tr15
+ */
+public class NameNormalizer {
+
+    private static final RuleBasedCollator sCompressingCollator;
+    static {
+        sCompressingCollator = (RuleBasedCollator)Collator.getInstance(null);
+        sCompressingCollator.setStrength(Collator.PRIMARY);
+        sCompressingCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION);
+    }
+
+    private static final RuleBasedCollator sComplexityCollator;
+    static {
+        sComplexityCollator = (RuleBasedCollator)Collator.getInstance(null);
+        sComplexityCollator.setStrength(Collator.TERTIARY);
+        sComplexityCollator.setAttribute(CollationAttribute.CASE_FIRST,
+                CollationAttribute.VALUE_LOWER_FIRST);
+    }
+
+    /**
+     * Converts the supplied name to a string that can be used to perform approximate matching
+     * of names.  It ignores non-letter characters and removes accents.
+     */
+    public static String normalize(String name) {
+        return Hex.encodeHex(sCompressingCollator.getSortKey(lettersOnly(name)), true);
+    }
+
+    /**
+     * Compares "complexity" of two names, which is determined by the presence
+     * of mixed case characters, accents and, if all else is equal, length.
+     */
+    public static int compareComplexity(String name1, String name2) {
+        int diff = sComplexityCollator.compare(lettersOnly(name1), lettersOnly(name2));
+        if (diff != 0) {
+            return diff;
+        }
+
+        return name1.length() - name2.length();
+    }
+
+    /**
+     * Returns a string containing just the letters from the original string.
+     */
+    private static String lettersOnly(String name) {
+        char[] letters = name.toCharArray();
+        int length = 0;
+        for (int i = 0; i < letters.length; i++) {
+            final char c = letters[i];
+            if (Character.isLetter(c)) {
+                letters[length++] = c;
+            }
+        }
+
+        if (length != letters.length) {
+            return new String(letters, 0, length);
+        }
+
+        return name;
+    }
+}
diff --git a/src/com/android/providers/contacts/NameSplitter.java b/src/com/android/providers/contacts/NameSplitter.java
new file mode 100644
index 0000000..aad3bc5
--- /dev/null
+++ b/src/com/android/providers/contacts/NameSplitter.java
@@ -0,0 +1,297 @@
+/*
+ * 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.contacts;
+
+import java.util.HashSet;
+import java.util.StringTokenizer;
+
+/**
+ * The purpose of this class is to split a full name into given names and last
+ * name. The logic only supports having a single last name. If the full name has
+ * multiple last names the output will be incorrect.
+ * <p>
+ * Core algorithm:
+ * <ol>
+ * <li>Remove the suffixes (III, Ph.D., M.D.).</li>
+ * <li>Remove the prefixes (Mr., Pastor, Reverend, Sir).</li>
+ * <li>Assign the last remaining token as the last name.</li>
+ * <li>If the previous word to the last name is one from LASTNAME_PREFIXES, use
+ * this word also as the last name.</li>
+ * <li>Assign the rest of the words as the "given names".</li>
+ * </ol>
+ */
+public class NameSplitter {
+
+    private final HashSet<String> mPrefixesSet;
+    private final HashSet<String> mSuffixesSet;
+    private final int mMaxSuffixLength;
+    private final HashSet<String> mLastNamePrefixesSet;
+    private final HashSet<String> mConjuctions;
+
+    public static class Name {
+        private String prefix;
+        private String givenNames;
+        private String middleName;
+        private String familyName;
+        private String suffix;
+
+        public String getPrefix() {
+            return prefix;
+        }
+
+        public String getGivenNames() {
+            return givenNames;
+        }
+
+        public String getMiddleName() {
+            return middleName;
+        }
+
+        public String getFamilyName() {
+            return familyName;
+        }
+
+        public String getSuffix() {
+            return suffix;
+        }
+    }
+
+    private static class NameTokenizer extends StringTokenizer {
+        private static final int MAX_TOKENS = 10;
+        private final String[] mTokens;
+        private int mDotBitmask;
+        private int mStartPointer;
+        private int mEndPointer;
+
+        public NameTokenizer(String fullName) {
+            super(fullName, " .,", true);
+
+            mTokens = new String[MAX_TOKENS];
+
+            // Iterate over tokens, skipping over empty ones and marking tokens that
+            // are followed by dots.
+            while (hasMoreTokens() && mEndPointer < MAX_TOKENS) {
+                final String token = nextToken();
+                if (token.length() > 0) {
+                    final char c = token.charAt(0);
+                    if (c == ' ' || c == ',') {
+                        continue;
+                    }
+                }
+
+                if (mEndPointer > 0 && token.charAt(0) == '.') {
+                    mDotBitmask |= (1 << (mEndPointer - 1));
+                } else {
+                    mTokens[mEndPointer] = token;
+                    mEndPointer++;
+                }
+            }
+        }
+
+        /**
+         * Returns true if the token is followed by a dot in the original full name.
+         */
+        public boolean hasDot(int index) {
+            return (mDotBitmask & (1 << index)) != 0;
+        }
+    }
+
+    /**
+     * Constructor.
+     *
+     * @param commonPrefixes comma-separated list of common prefixes,
+     *            e.g. "Mr, Ms, Mrs"
+     * @param commonLastNamePrefixes comma-separated list of common last name prefixes,
+     *           e.g. "d', st, st., von"
+     * @param commonSuffixes comma-separated list of common suffixes,
+     *            e.g. "Jr, M.D., MD, D.D.S."
+     * @param commonConjunctions comma-separated list of common conjuctions,
+     *            e.g. "AND, Or"
+     */
+    public NameSplitter(String commonPrefixes, String commonLastNamePrefixes,
+            String commonSuffixes, String commonConjunctions) {
+        mPrefixesSet = convertToSet(commonPrefixes);
+        mLastNamePrefixesSet = convertToSet(commonLastNamePrefixes);
+        mSuffixesSet = convertToSet(commonSuffixes);
+        mConjuctions = convertToSet(commonConjunctions);
+
+        int maxLength = 0;
+        for (String suffix : mSuffixesSet) {
+            if (suffix.length() > maxLength) {
+                maxLength = suffix.length();
+            }
+        }
+
+        mMaxSuffixLength = maxLength;
+    }
+
+    /**
+     * Converts a comma-separated list of Strings to a set of Strings. Trims strings
+     * and converts them to upper case.
+     */
+    private static HashSet<String> convertToSet(String strings) {
+        HashSet<String> set = new HashSet<String>();
+        if (strings != null) {
+            String[] split = strings.split(",");
+            for (int i = 0; i < split.length; i++) {
+                set.add(split[i].trim().toUpperCase());
+            }
+        }
+        return set;
+    }
+
+    /**
+     * Parses a full name and returns parsed components in the Name object.
+     */
+    public void split(Name name, String fullName) {
+        if (fullName == null) {
+            return;
+        }
+
+        NameTokenizer tokens = new NameTokenizer(fullName);
+        parsePrefix(name, tokens);
+        parseSuffix(name, tokens);
+        parseLastName(name, tokens);
+        parseMiddleName(name, tokens);
+        parseGivenNames(name, tokens);
+    }
+
+    /**
+     * Parses the first word from the name if it is a prefix.
+     */
+    private void parsePrefix(Name name, NameTokenizer tokens) {
+        if (tokens.mStartPointer == tokens.mEndPointer) {
+            return;
+        }
+
+        String firstToken = tokens.mTokens[tokens.mStartPointer];
+        if (mPrefixesSet.contains(firstToken.toUpperCase())) {
+            name.prefix = firstToken;
+            tokens.mStartPointer++;
+        }
+    }
+
+    /**
+     * Parses the last word(s) from the name if it is a suffix.
+     */
+    private void parseSuffix(Name name, NameTokenizer tokens) {
+        if (tokens.mStartPointer == tokens.mEndPointer) {
+            return;
+        }
+
+        String lastToken = tokens.mTokens[tokens.mEndPointer - 1];
+        if (lastToken.length() > mMaxSuffixLength) {
+            return;
+        }
+
+        String normalized = lastToken.toUpperCase();
+        if (mSuffixesSet.contains(normalized)) {
+            name.suffix = lastToken;
+            tokens.mEndPointer--;
+            return;
+        }
+
+        if (tokens.hasDot(tokens.mEndPointer - 1)) {
+            lastToken += '.';
+        }
+        normalized += ".";
+
+        // Take care of suffixes like M.D. and D.D.S.
+        int pos = tokens.mEndPointer - 1;
+        while (normalized.length() <= mMaxSuffixLength) {
+
+            if (mSuffixesSet.contains(normalized)) {
+                name.suffix = lastToken;
+                tokens.mEndPointer = pos;
+                return;
+            }
+
+            if (pos == tokens.mStartPointer) {
+                break;
+            }
+
+            pos--;
+            if (tokens.hasDot(pos)) {
+                lastToken = tokens.mTokens[pos] + "." + lastToken;
+            } else {
+                lastToken = tokens.mTokens[pos] + " " + lastToken;
+            }
+
+            normalized = tokens.mTokens[pos].toUpperCase() + "." + normalized;
+        }
+    }
+
+    private void parseLastName(Name name, NameTokenizer tokens) {
+        if (tokens.mStartPointer == tokens.mEndPointer) {
+            return;
+        }
+
+        name.familyName = tokens.mTokens[tokens.mEndPointer - 1];
+        tokens.mEndPointer--;
+
+        // Take care of last names like "D'Onofrio" and "von Cliburn"
+        if ((tokens.mEndPointer - tokens.mStartPointer) > 0) {
+            String lastNamePrefix = tokens.mTokens[tokens.mEndPointer - 1];
+            final String normalized = lastNamePrefix.toUpperCase();
+            if (mLastNamePrefixesSet.contains(normalized)
+                    || mLastNamePrefixesSet.contains(normalized + ".")) {
+                if (tokens.hasDot(tokens.mEndPointer - 1)) {
+                    lastNamePrefix += '.';
+                }
+                name.familyName = lastNamePrefix + " " + name.familyName;
+                tokens.mEndPointer--;
+            }
+        }
+    }
+
+
+    private void parseMiddleName(Name name, NameTokenizer tokens) {
+        if (tokens.mStartPointer == tokens.mEndPointer) {
+            return;
+        }
+
+        if ((tokens.mEndPointer - tokens.mStartPointer) > 1) {
+            if ((tokens.mEndPointer - tokens.mStartPointer) == 2
+                    || !mConjuctions.contains(tokens.mTokens[tokens.mEndPointer - 2].
+                            toUpperCase())) {
+                name.middleName = tokens.mTokens[tokens.mEndPointer - 1];
+                tokens.mEndPointer--;
+            }
+        }
+    }
+
+    private void parseGivenNames(Name name, NameTokenizer tokens) {
+        if (tokens.mStartPointer == tokens.mEndPointer) {
+            return;
+        }
+
+        if ((tokens.mEndPointer - tokens.mStartPointer) == 1) {
+            name.givenNames = tokens.mTokens[tokens.mStartPointer];
+        } else {
+            StringBuilder sb = new StringBuilder();
+            for (int i = tokens.mStartPointer; i < tokens.mEndPointer; i++) {
+                if (i != tokens.mStartPointer) {
+                    sb.append(' ');
+                }
+                sb.append(tokens.mTokens[i]);
+                if (tokens.hasDot(i)) {
+                    sb.append('.');
+                }
+            }
+            name.givenNames = sb.toString();
+        }
+    }
+}
diff --git a/src/com/android/providers/contacts/OpenHelper.java b/src/com/android/providers/contacts/OpenHelper.java
new file mode 100644
index 0000000..d7f34c4
--- /dev/null
+++ b/src/com/android/providers/contacts/OpenHelper.java
@@ -0,0 +1,1162 @@
+/*
+ * 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.contacts;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
+import android.os.Binder;
+import android.provider.BaseColumns;
+import android.provider.SocialContract.Activities;
+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;
+
+import android.telephony.PhoneNumberUtils;
+import android.util.Log;
+
+import java.util.HashMap;
+import java.util.LinkedList;
+
+/**
+ * Database open helper for contacts and social activity data. Designed as a
+ * singleton to make sure that all {@link android.content.ContentProvider} users get the same
+ * reference. Provides handy methods for maintaining package and mime-type
+ * lookup tables.
+ */
+/* package */ class OpenHelper extends SQLiteOpenHelper {
+    private static final String TAG = "OpenHelper";
+
+    private static final int DATABASE_VERSION = 38;
+    private static final String DATABASE_NAME = "contacts2.db";
+    private static final String DATABASE_PRESENCE = "presence_db";
+
+    public interface Tables {
+        public static final String ACCOUNTS = "accounts";
+        public static final String AGGREGATES = "aggregates";
+        public static final String CONTACTS = "contacts";
+        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";
+
+        public static final String AGGREGATES_JOIN_PRESENCE_PRIMARY_PHONE = "aggregates "
+                + "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_MIMETYPES = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id)";
+
+        public static final String DATA_JOIN_MIMETYPE_CONTACTS = "data "
+                + "LEFT OUTER JOIN mimetypes ON (data.mimetype_id = mimetypes._id) "
+                + "LEFT OUTER JOIN contacts ON (data.contact_id = contacts._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_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 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_MIMETYPES = "activities "
+                + "LEFT OUTER JOIN mimetypes ON (activities.mimetype_id = mimetypes._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 packages ON (contacts.package_id = packages._id) "
+                + "LEFT OUTER JOIN aggregates ON (contacts.aggregate_id = aggregates._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 "
+                + "INNER JOIN contacts ON (name_lookup.contact_id = contacts._id)";
+
+        public static final String AGGREGATION_EXCEPTIONS_JOIN_CONTACTS = "agg_exceptions "
+                + "INNER JOIN contacts contacts1 "
+                + "ON (agg_exceptions.contact_id1 = contacts1._id) ";
+
+        public static final String AGGREGATION_EXCEPTIONS_JOIN_CONTACTS_TWICE = "agg_exceptions "
+                + "INNER JOIN contacts contacts1 "
+                + "ON (agg_exceptions.contact_id1 = contacts1._id) "
+                + "INNER JOIN contacts contacts2 "
+                + "ON (agg_exceptions.contact_id2 = contacts2._id) ";
+
+        public static final String CONTACTS_JOIN_CONTACT_OPTIONS = "contacts "
+                + "LEFT OUTER JOIN contact_options ON (contacts._id = contact_options._id)";
+    }
+
+    public interface Clauses {
+        public static final String WHERE_IM_MATCHES = MimetypesColumns.MIMETYPE + "=" + Im.MIMETYPE
+                + " AND " + Im.PROTOCOL + "=? AND " + Im.DATA + "=?";
+
+        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 {
+        public static final String OPTIMAL_PRIMARY_PHONE_ID = "optimal_phone_id";
+        public static final String OPTIMAL_PRIMARY_PHONE_PACKAGE_ID = "optimal_phone_package_id";
+        public static final String FALLBACK_PRIMARY_PHONE_ID = "fallback_phone_id";
+
+        public static final String OPTIMAL_PRIMARY_EMAIL_ID = "optimal_email_id";
+        public static final String OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID = "optimal_email_package_id";
+        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 {
+        public static final String PACKAGE_ID = "package_id";
+        public static final String MIMETYPE_ID = "mimetype_id";
+    }
+
+    public interface PhoneLookupColumns {
+        public static final String _ID = BaseColumns._ID;
+        public static final String DATA_ID = "data_id";
+        public static final String CONTACT_ID = "contact_id";
+        public static final String NORMALIZED_NUMBER = "normalized_number";
+    }
+
+    public interface NameLookupColumns {
+        public static final String _ID = BaseColumns._ID;
+        public static final String CONTACT_ID = "contact_id";
+        public static final String NORMALIZED_NAME = "normalized_name";
+        public static final String NAME_TYPE = "name_type";
+    }
+
+    public final static class NameLookupType {
+        public static final int FULL_NAME = 0;
+        public static final int FULL_NAME_CONCATENATED = 1;
+        public static final int FULL_NAME_REVERSE = 2;
+        public static final int FULL_NAME_REVERSE_CONCATENATED = 3;
+        public static final int FULL_NAME_WITH_NICKNAME = 4;
+        public static final int FULL_NAME_WITH_NICKNAME_REVERSE = 5;
+        public static final int GIVEN_NAME_ONLY = 6;
+        public static final int GIVEN_NAME_ONLY_AS_NICKNAME = 7;
+        public static final int FAMILY_NAME_ONLY = 8;
+        public static final int FAMILY_NAME_ONLY_AS_NICKNAME = 9;
+        public static final int NICKNAME = 10;
+        public static final int EMAIL_BASED_NICKNAME = 11;
+
+        // This is the highest name lookup type code plus one
+        public static final int TYPE_COUNT = 12;
+
+        public static boolean isBasedOnStructuredName(int nameLookupType) {
+            return nameLookupType != NameLookupType.EMAIL_BASED_NICKNAME
+                    && nameLookupType != NameLookupType.NICKNAME;
+        }
+    }
+
+    public interface PackagesColumns {
+        public static final String _ID = BaseColumns._ID;
+        public static final String PACKAGE = "package";
+    }
+
+    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 {
+        public static final String _ID = BaseColumns._ID;
+        public static final String CONTACT_ID1 = "contact_id1";
+        public static final String CONTACT_ID2 = "contact_id2";
+    }
+
+    public interface RestrictionExceptionsColumns {
+        public static final String PACKAGE_PROVIDER_ID = "package_provider_id";
+        public static final String PACKAGE_CLIENT_ID = "package_client_id";
+    }
+
+    public interface ContactOptionsColumns {
+        public static final String _ID = BaseColumns._ID;
+        public static final String CUSTOM_RINGTONE = "custom_ringtone";
+        public static final String SEND_TO_VOICEMAIL = "send_to_voicemail";
+    }
+
+    public interface NicknameLookupColumns {
+        public static final String NAME = "name";
+        public static final String CLUSTER = "cluster";
+    }
+
+    private static final String[] NICKNAME_LOOKUP_COLUMNS = new String[] {
+        NicknameLookupColumns.CLUSTER
+    };
+
+    private static final int COL_NICKNAME_LOOKUP_CLUSTER = 0;
+
+    /** In-memory cache of previously found mimetype mappings */
+    private final HashMap<String, Long> mMimetypeCache = new HashMap<String, Long>();
+    /** In-memory cache of previously found package name mappings */
+    private final HashMap<String, Long> mPackageCache = new HashMap<String, Long>();
+
+
+    /** Compiled statements for querying and inserting mappings */
+    private SQLiteStatement mMimetypeQuery;
+    private SQLiteStatement mPackageQuery;
+    private SQLiteStatement mAggregateIdQuery;
+    private SQLiteStatement mAggregateIdUpdate;
+    private SQLiteStatement mMimetypeInsert;
+    private SQLiteStatement mPackageInsert;
+    private SQLiteStatement mNameLookupInsert;
+
+    private SQLiteStatement mDataMimetypeQuery;
+    private SQLiteStatement mActivitiesMimetypeQuery;
+
+    private final Context mContext;
+    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;
+
+    public static synchronized OpenHelper getInstance(Context context) {
+        if (sSingleton == null) {
+            sSingleton = new OpenHelper(context);
+        }
+        return sSingleton;
+    }
+
+    /**
+     * Private constructor, callers except unit tests should obtain an instance through
+     * {@link #getInstance(Context)} instead.
+     */
+    /* package */ OpenHelper(Context context) {
+        super(context, DATABASE_NAME, null, DATABASE_VERSION);
+        Log.i(TAG, "Creating OpenHelper");
+
+        mContext = context;
+        mCache = new RestrictionExceptionsCache();
+        mCache.loadFromDatabase(context, getReadableDatabase());
+    }
+
+    @Override
+    public void onOpen(SQLiteDatabase db) {
+        // Create compiled statements for package and mimetype lookups
+        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.MIMETYPES + "("
+                + MimetypesColumns.MIMETYPE + ") VALUES (?)");
+        mPackageInsert = db.compileStatement("INSERT INTO " + Tables.PACKAGES + "("
+                + PackagesColumns.PACKAGE + ") VALUES (?)");
+
+        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";
+
+        db.execSQL("ATTACH DATABASE ':memory:' AS " + DATABASE_PRESENCE + ";");
+        db.execSQL("CREATE TABLE IF NOT EXISTS " + tableName + " ("+
+                BaseColumns._ID + " INTEGER PRIMARY KEY," +
+                Presence.AGGREGATE_ID + " INTEGER REFERENCES aggregates(_id)," +
+                Presence.DATA_ID + " INTEGER REFERENCES data(_id)," +
+                Presence.IM_PROTOCOL + " TEXT," +
+                Presence.IM_HANDLE + " TEXT," +
+                Presence.IM_ACCOUNT + " TEXT," +
+                Presence.PRESENCE_STATUS + " INTEGER," +
+                Presence.PRESENCE_CUSTOM_STATUS + " TEXT," +
+                "UNIQUE(" + Presence.IM_PROTOCOL + ", " + Presence.IM_HANDLE + ", " + Presence.IM_ACCOUNT + ")" +
+        ");");
+
+        db.execSQL("CREATE INDEX IF NOT EXISTS " + indexName + " ON " + Tables.PRESENCE + " ("
+                + Presence.AGGREGATE_ID + ");");
+    }
+
+    @Override
+    public void onCreate(SQLiteDatabase db) {
+        Log.i(TAG, "Bootstrapping database");
+
+        db.execSQL("CREATE TABLE " + Tables.ACCOUNTS + " (" +
+                BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                Accounts.NAME + " TEXT," +
+                Accounts.TYPE + " TEXT," +
+                Accounts.DATA1 + " TEXT," +
+                Accounts.DATA2 + " TEXT, " +
+                Accounts.DATA3 + " TEXT, " +
+                Accounts.DATA4 + " TEXT, " +
+                Accounts.DATA5 + " TEXT, " +
+                " UNIQUE(" + Accounts.NAME + ", " + Accounts.TYPE + ") " +
+        ");");
+
+        // One row per group of contacts corresponding to the same person
+        db.execSQL("CREATE TABLE " + Tables.AGGREGATES + " (" +
+                BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                Aggregates.DISPLAY_NAME + " TEXT," +
+                Aggregates.TIMES_CONTACTED + " INTEGER," +
+                Aggregates.LAST_TIME_CONTACTED + " INTEGER," +
+                Aggregates.STARRED + " INTEGER," +
+                AggregatesColumns.OPTIMAL_PRIMARY_PHONE_ID + " INTEGER REFERENCES data(_id)," +
+                AggregatesColumns.OPTIMAL_PRIMARY_PHONE_PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                AggregatesColumns.FALLBACK_PRIMARY_PHONE_ID + " INTEGER REFERENCES data(_id)," +
+                AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_ID + " INTEGER REFERENCES data(_id)," +
+                AggregatesColumns.OPTIMAL_PRIMARY_EMAIL_PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                AggregatesColumns.FALLBACK_PRIMARY_EMAIL_ID + " INTEGER REFERENCES data(_id)," +
+                AggregatesColumns.SINGLE_RESTRICTED_PACKAGE_ID + " INTEGER REFERENCES package(_id)," +
+                Aggregates.PHOTO_ID + " INTEGER REFERENCES data(_id)," +
+                Aggregates.CUSTOM_RINGTONE + " TEXT," +
+                Aggregates.SEND_TO_VOICEMAIL + " INTEGER NOT NULL DEFAULT 0," +
+                Aggregates.IN_VISIBLE_GROUP + " INTEGER NOT NULL DEFAULT 1" +
+        ");");
+
+        // Contacts table
+        db.execSQL("CREATE TABLE " + Tables.CONTACTS + " (" +
+                Contacts._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                ContactsColumns.PACKAGE_ID + " INTEGER REFERENCES package(_id) NOT NULL," +
+                Contacts.IS_RESTRICTED + " INTEGER NOT NULL DEFAULT 0," +
+                Contacts.ACCOUNTS_ID + " INTEGER REFERENCES accounts(_id)," +
+                Contacts.SOURCE_ID + " TEXT," +
+                Contacts.VERSION + " INTEGER NOT NULL DEFAULT 1," +
+                Contacts.DIRTY + " INTEGER NOT NULL DEFAULT 1," +
+                Contacts.AGGREGATE_ID + " INTEGER " +
+        ");");
+
+        // Contact options table. It has the same primary key as the corresponding contact.
+        db.execSQL("CREATE TABLE " + Tables.CONTACT_OPTIONS + " (" +
+                ContactOptionsColumns._ID + " INTEGER PRIMARY KEY," +
+                ContactOptionsColumns.CUSTOM_RINGTONE + " TEXT," +
+                ContactOptionsColumns.SEND_TO_VOICEMAIL + " INTEGER NOT NULL DEFAULT 0" +
+       ");");
+
+        // Package name mapping table
+        db.execSQL("CREATE TABLE " + Tables.PACKAGES + " (" +
+                PackagesColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                PackagesColumns.PACKAGE + " 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
+        db.execSQL("CREATE TABLE " + Tables.DATA + " (" +
+                Data._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                DataColumns.MIMETYPE_ID + " INTEGER REFERENCES mimetype(_id) NOT NULL," +
+                Data.CONTACT_ID + " INTEGER NOT NULL," +
+                Data.IS_PRIMARY + " INTEGER NOT NULL DEFAULT 0," +
+                Data.IS_SUPER_PRIMARY + " INTEGER NOT NULL DEFAULT 0," +
+                Data.DATA_VERSION + " INTEGER NOT NULL DEFAULT 0," +
+                Data.DATA1 + " TEXT," +
+                Data.DATA2 + " TEXT," +
+                Data.DATA3 + " TEXT," +
+                Data.DATA4 + " TEXT," +
+                Data.DATA5 + " TEXT," +
+                Data.DATA6 + " TEXT," +
+                Data.DATA7 + " TEXT," +
+                Data.DATA8 + " TEXT," +
+                Data.DATA9 + " TEXT," +
+                Data.DATA10 + " TEXT" +
+        ");");
+
+        /**
+         * set contact.dirty whenever the contact is updated and the new version does not explicity
+         * clear the dirty flag
+         *
+         * Want to have a data row that has the server version of the contact. Then when I save
+         * an entry from the server into the provider I will set the server version of the data
+         * while also clearing the dirty flag of the contact.
+         *
+         * increment the contact.version whenever the contact is updated
+         */
+        db.execSQL("CREATE TRIGGER " + Tables.CONTACTS + "_updated1 "
+                + "   BEFORE UPDATE ON " + Tables.CONTACTS
+                + " BEGIN "
+                + "   UPDATE " + Tables.CONTACTS
+                + "     SET "
+                +         Contacts.VERSION + "=OLD." + Contacts.VERSION + "+1, "
+                +         Contacts.DIRTY + "=1"
+                + "     WHERE " + Contacts._ID + "=OLD." + Contacts._ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TRIGGER " + Tables.CONTACTS + "_deleted "
+                + "   BEFORE DELETE ON " + Tables.CONTACTS
+                + " BEGIN "
+                + "   DELETE FROM " + Tables.DATA
+                + "     WHERE " + Data.CONTACT_ID + "=OLD." + Contacts._ID + ";"
+                + "   DELETE FROM " + Tables.PHONE_LOOKUP
+                + "     WHERE " + PhoneLookupColumns.CONTACT_ID + "=OLD." + Contacts._ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TRIGGER " + Tables.DATA + "_updated AFTER UPDATE ON " + Tables.DATA
+                + " BEGIN "
+                + "   UPDATE " + Tables.DATA
+                + "     SET " + Data.DATA_VERSION + "=OLD." + Data.DATA_VERSION + "+1 "
+                + "     WHERE " + Data._ID + "=OLD." + Data._ID + ";"
+                + "   UPDATE " + Tables.CONTACTS
+                + "     SET " + Contacts.DIRTY + "=1"
+                + "     WHERE " + Contacts._ID + "=OLD." + Contacts._ID + ";"
+                + " END");
+
+        db.execSQL("CREATE TRIGGER " + Tables.DATA + "_deleted BEFORE DELETE ON " + Tables.DATA
+                + " BEGIN "
+                + "   UPDATE " + Tables.CONTACTS
+                + "     SET " + Contacts.DIRTY + "=1"
+                + "     WHERE " + Contacts._ID + "=OLD." + Contacts._ID + ";"
+                + "   DELETE FROM " + Tables.PHONE_LOOKUP
+                + "     WHERE " + PhoneLookupColumns.DATA_ID + "=OLD." + Data._ID + ";"
+                + " END");
+
+        // Private phone numbers table used for lookup
+        db.execSQL("CREATE TABLE " + Tables.PHONE_LOOKUP + " (" +
+                PhoneLookupColumns._ID + " INTEGER PRIMARY KEY," +
+                PhoneLookupColumns.DATA_ID + " INTEGER REFERENCES data(_id) NOT NULL," +
+                PhoneLookupColumns.CONTACT_ID + " INTEGER REFERENCES contacts(_id) NOT NULL," +
+                PhoneLookupColumns.NORMALIZED_NUMBER + " TEXT NOT NULL" +
+        ");");
+
+        db.execSQL("CREATE INDEX phone_lookup_index ON " + Tables.PHONE_LOOKUP + " (" +
+                PhoneLookupColumns.NORMALIZED_NUMBER + " ASC, " +
+                PhoneLookupColumns.CONTACT_ID + ", " +
+                PhoneLookupColumns.DATA_ID +
+        ");");
+
+        // Private name/nickname table used for lookup
+        db.execSQL("CREATE TABLE " + Tables.NAME_LOOKUP + " (" +
+                NameLookupColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                NameLookupColumns.CONTACT_ID + " INTEGER REFERENCES contacts(_id) NOT NULL," +
+                NameLookupColumns.NORMALIZED_NAME + " TEXT," +
+                NameLookupColumns.NAME_TYPE + " INTEGER" +
+        ");");
+
+        db.execSQL("CREATE INDEX name_lookup_index ON " + Tables.NAME_LOOKUP + " (" +
+                NameLookupColumns.NORMALIZED_NAME + " ASC, " +
+                NameLookupColumns.NAME_TYPE + " ASC, " +
+                NameLookupColumns.CONTACT_ID +
+        ");");
+
+        db.execSQL("CREATE TABLE " + Tables.NICKNAME_LOOKUP + " (" +
+                NicknameLookupColumns.NAME + " TEXT," +
+                NicknameLookupColumns.CLUSTER + " TEXT" +
+        ");");
+
+        db.execSQL("CREATE UNIQUE INDEX nickname_lookup_index ON " + Tables.NICKNAME_LOOKUP + " (" +
+                NicknameLookupColumns.NAME + ", " +
+                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, " +
+                AggregationExceptionColumns.CONTACT_ID1 + " INTEGER REFERENCES contacts(_id), " +
+                AggregationExceptionColumns.CONTACT_ID2 + " INTEGER REFERENCES contacts(_id)" +
+		");");
+
+        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS aggregation_exception_index1 ON " +
+                Tables.AGGREGATION_EXCEPTIONS + " (" +
+                AggregationExceptionColumns.CONTACT_ID1 + ", " +
+                AggregationExceptionColumns.CONTACT_ID2 +
+        ");");
+
+        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS aggregation_exception_index2 ON " +
+                Tables.AGGREGATION_EXCEPTIONS + " (" +
+                AggregationExceptionColumns.CONTACT_ID2 + ", " +
+                AggregationExceptionColumns.CONTACT_ID1 +
+        ");");
+
+        // Restriction exceptions table
+        db.execSQL("CREATE TABLE " + Tables.RESTRICTION_EXCEPTIONS + " (" +
+                BaseColumns._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                RestrictionExceptions.PACKAGE_PROVIDER + " TEXT NOT NULL, " +
+                RestrictionExceptions.PACKAGE_CLIENT + " TEXT NOT NULL, " +
+                RestrictionExceptionsColumns.PACKAGE_PROVIDER_ID + " INTEGER NOT NULL, " +
+                RestrictionExceptionsColumns.PACKAGE_CLIENT_ID + " INTEGER NOT NULL" +
+        ");");
+
+        // Activities table
+        db.execSQL("CREATE TABLE " + Tables.ACTIVITIES + " (" +
+                Activities._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
+                ActivitiesColumns.PACKAGE_ID + " INTEGER REFERENCES package(_id) NOT NULL," +
+                ActivitiesColumns.MIMETYPE_ID + " INTEGER REFERENCES mimetype(_id) NOT NULL," +
+                Activities.RAW_ID + " TEXT," +
+                Activities.IN_REPLY_TO + " TEXT," +
+                Activities.AUTHOR_CONTACT_ID +  " INTEGER REFERENCES contacts(_id)," +
+                Activities.TARGET_CONTACT_ID + " INTEGER REFERENCES contacts(_id)," +
+                Activities.PUBLISHED + " INTEGER NOT NULL," +
+                Activities.THREAD_PUBLISHED + " INTEGER NOT NULL," +
+                Activities.TITLE + " TEXT NOT NULL," +
+                Activities.SUMMARY + " TEXT," +
+                Activities.LINK + " TEXT, " +
+                Activities.THUMBNAIL + " BLOB" +
+        ");");
+
+        loadNicknameLookupTable(db);
+    }
+
+    @Override
+    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+        Log.i(TAG, "Upgrading from version " + oldVersion + " to " + newVersion
+                + ", data will be lost!");
+
+        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.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 + ";");
+
+        // TODO: we should not be dropping agg_exceptions and contact_options. In case that table's
+        // schema changes, we should try to preserve the data, because it was entered by the user
+        // and has never been synched to the server.
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.AGGREGATION_EXCEPTIONS + ";");
+        db.execSQL("DROP TABLE IF EXISTS " + Tables.CONTACT_OPTIONS + ";");
+
+        onCreate(db);
+    }
+
+    /**
+     * Wipes all data except mime type and package lookup tables.
+     */
+    public void wipeData() {
+        SQLiteDatabase db = getWritableDatabase();
+        db.execSQL("DELETE FROM " + Tables.AGGREGATES + ";");
+        db.execSQL("DELETE FROM " + Tables.CONTACTS + ";");
+        db.execSQL("DELETE FROM " + Tables.CONTACT_OPTIONS + ";");
+        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 + ";");
+
+        // Note: we are not removing reference data from Tables.NICKNAME_LOOKUP
+
+        db.execSQL("VACUUM;");
+    }
+
+    /**
+     * Return the {@link ApplicationInfo#uid} for the given package name.
+     */
+    public static int getUidForPackageName(PackageManager pm, String packageName) {
+        try {
+            ApplicationInfo clientInfo = pm.getApplicationInfo(packageName, 0 /* no flags */);
+            return clientInfo.uid;
+        } catch (NameNotFoundException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * Perform an internal string-to-integer lookup using the compiled
+     * {@link SQLiteStatement} provided, using the in-memory cache to speed up
+     * lookups. If a mapping isn't found in cache or database, it will be
+     * created. All new, uncached answers are added to the cache automatically.
+     *
+     * @param query Compiled statement used to query for the mapping.
+     * @param insert Compiled statement used to insert a new mapping when no
+     *            existing one is found in cache or from query.
+     * @param value Value to find mapping for.
+     * @param cache In-memory cache of previous answers.
+     * @return An unique integer mapping for the given value.
+     */
+    private synchronized long getCachedId(SQLiteStatement query, SQLiteStatement insert,
+            String value, HashMap<String, Long> cache) {
+        // Try an in-memory cache lookup
+        if (cache.containsKey(value)) {
+            return cache.get(value);
+        }
+
+        long id = -1;
+        try {
+            // Try searching database for mapping
+            DatabaseUtils.bindObjectToProgram(query, 1, value);
+            id = query.simpleQueryForLong();
+        } catch (SQLiteDoneException e) {
+            // Nothing found, so try inserting new mapping
+            DatabaseUtils.bindObjectToProgram(insert, 1, value);
+            id = insert.executeInsert();
+        }
+
+        if (id != -1) {
+            // Cache and return the new answer
+            cache.put(value, id);
+            return id;
+        } else {
+            // Otherwise throw if no mapping found or created
+            throw new IllegalStateException("Couldn't find or create internal "
+                    + "lookup table entry for value " + value);
+        }
+    }
+
+    /**
+     * 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) {
+        // Make sure compiled statements are ready by opening database
+        getReadableDatabase();
+        return getCachedId(mPackageQuery, mPackageInsert, packageName, mPackageCache);
+    }
+
+    /**
+     * 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) {
+        // Make sure compiled statements are ready by opening database
+        getReadableDatabase();
+        return getCachedId(mMimetypeQuery, mMimetypeInsert, mimetype, mMimetypeCache);
+    }
+
+    /**
+     * Find the mimetype for the given {@link Data#_ID}.
+     */
+    public String getDataMimeType(long dataId) {
+        // Make sure compiled statements are ready by opening database
+        getReadableDatabase();
+        try {
+            // Try database query to find mimetype
+            DatabaseUtils.bindObjectToProgram(mDataMimetypeQuery, 1, dataId);
+            String mimetype = mDataMimetypeQuery.simpleQueryForString();
+            return mimetype;
+        } catch (SQLiteDoneException e) {
+            // No valid mapping found, so return null
+            return null;
+        }
+    }
+
+    /**
+     * Find the mime-type for the given {@link Activities#_ID}.
+     */
+    public String getActivityMimeType(long activityId) {
+        // Make sure compiled statements are ready by opening database
+        getReadableDatabase();
+        try {
+            // Try database query to find mimetype
+            DatabaseUtils.bindObjectToProgram(mActivitiesMimetypeQuery, 1, activityId);
+            String mimetype = mActivitiesMimetypeQuery.simpleQueryForString();
+            return mimetype;
+        } catch (SQLiteDoneException e) {
+            // No valid mapping found, so return null
+            return null;
+        }
+    }
+
+    /**
+     * 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) {
+        getWritableDatabase();
+        DatabaseUtils.bindObjectToProgram(mAggregateIdUpdate, 1, aggregateId);
+        DatabaseUtils.bindObjectToProgram(mAggregateIdUpdate, 2, contactId);
+        mAggregateIdUpdate.execute();
+    }
+
+    /**
+     * Returns aggregate ID for the given contact or zero if it is NULL.
+     */
+    public long getAggregateId(long contactId) {
+        getReadableDatabase();
+        try {
+            DatabaseUtils.bindObjectToProgram(mAggregateIdQuery, 1, contactId);
+            return mAggregateIdQuery.simpleQueryForLong();
+        } catch (SQLiteDoneException e) {
+            // No valid mapping found, so return -1
+            return 0;
+        }
+    }
+
+    /**
+     * Inserts a record in the {@link Tables#NAME_LOOKUP} table.
+     */
+    public void insertNameLookup(long contactId, int lookupType, String name) {
+        getWritableDatabase();
+        DatabaseUtils.bindObjectToProgram(mNameLookupInsert, 1, contactId);
+        DatabaseUtils.bindObjectToProgram(mNameLookupInsert, 2, lookupType);
+        DatabaseUtils.bindObjectToProgram(mNameLookupInsert, 3, name);
+        mNameLookupInsert.executeInsert();
+    }
+
+    public static void buildPhoneLookupQuery(SQLiteQueryBuilder qb, final String number) {
+        final String normalizedNumber = PhoneNumberUtils.toCallerIDMinMatch(number);
+        final StringBuilder tables = new StringBuilder();
+        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_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 + ", ");
+        qb.appendWhereEscapeString(number);
+        qb.appendWhere(")");
+    }
+
+
+    /**
+     * Loads common nickname mappings into the database.
+     */
+    private void loadNicknameLookupTable(SQLiteDatabase db) {
+        String[] strings = mContext.getResources().getStringArray(
+                com.android.internal.R.array.common_nicknames);
+        if (strings == null || strings.length == 0) {
+            return;
+        }
+
+        SQLiteStatement nicknameLookupInsert = db.compileStatement("INSERT INTO "
+                + Tables.NICKNAME_LOOKUP + "(" + NicknameLookupColumns.NAME + ","
+                + NicknameLookupColumns.CLUSTER + ") VALUES (?,?)");
+
+        for (int clusterId = 0; clusterId < strings.length; clusterId++) {
+            String[] names = strings[clusterId].split(",");
+            for (int j = 0; j < names.length; j++) {
+                String name = NameNormalizer.normalize(names[j]);
+                try {
+                    DatabaseUtils.bindObjectToProgram(nicknameLookupInsert, 1, name);
+                    DatabaseUtils.bindObjectToProgram(nicknameLookupInsert, 2,
+                            String.valueOf(clusterId));
+                    nicknameLookupInsert.executeInsert();
+                } catch (SQLiteException e) {
+
+                    // Print the exception and keep going - this is not a fatal error
+                    Log.e(TAG, "Cannot insert nickname: " + names[j], e);
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns common nickname cluster IDs for a given name. For example, it
+     * will return the same value for "Robert", "Bob" and "Rob". Some names belong to multiple
+     * clusters, e.g. Leo could be Leonard or Leopold.
+     *
+     * May return null.
+     *
+     * @param normalizedName A normalized first name, see {@link NameNormalizer#normalize}.
+     */
+    public String[] getCommonNicknameClusters(String normalizedName) {
+        if (mNicknameClusterCache == null) {
+            mNicknameClusterCache = new HashMap<String, String[]>();
+        }
+
+        synchronized (mNicknameClusterCache) {
+            if (mNicknameClusterCache.containsKey(normalizedName)) {
+                return mNicknameClusterCache.get(normalizedName);
+            }
+        }
+
+        String[] clusters = null;
+        SQLiteDatabase db = getReadableDatabase();
+
+        Cursor cursor = db.query(Tables.NICKNAME_LOOKUP, NICKNAME_LOOKUP_COLUMNS,
+                NicknameLookupColumns.NAME + "=?", new String[] { normalizedName },
+                null, null, null);
+        try {
+            int count = cursor.getCount();
+            if (count > 0) {
+                clusters = new String[count];
+                for (int i = 0; i < count; i++) {
+                    cursor.moveToNext();
+                    clusters[i] = cursor.getString(COL_NICKNAME_LOOKUP_CLUSTER);
+                }
+            }
+        } finally {
+            cursor.close();
+        }
+
+        synchronized (mNicknameClusterCache) {
+            mNicknameClusterCache.put(normalizedName, clusters);
+        }
+
+        return clusters;
+    }
+
+    /**
+     * Add a {@link RestrictionExceptions} record. This will update the
+     * in-memory lookup table, and write to the database when needed. Any
+     * callers should enforce that the {@link Binder#getCallingUid()} has the
+     * authority to grant exceptions.
+     */
+    public void addRestrictionException(Context context, ContentValues values) {
+        final PackageManager pm = context.getPackageManager();
+        final SQLiteDatabase db = this.getWritableDatabase();
+
+        // Read incoming package values and find lookup values
+        final String packageProvider = values.getAsString(RestrictionExceptions.PACKAGE_PROVIDER);
+        final String packageClient = values.getAsString(RestrictionExceptions.PACKAGE_CLIENT);
+        final long packageProviderId = getPackageId(packageProvider);
+        final long packageClientId = getPackageId(packageClient);
+
+        // Find the client UID to update our internal lookup table and write the
+        // exception to our database if we changed the in-memory cache.
+        final int clientUid = getUidForPackageName(pm, packageClient);
+        boolean cacheChanged = mCache.addException(packageProviderId, clientUid);
+        if (cacheChanged) {
+            values.put(RestrictionExceptionsColumns.PACKAGE_PROVIDER_ID, packageProviderId);
+            values.put(RestrictionExceptionsColumns.PACKAGE_CLIENT_ID, packageClientId);
+            values.remove(RestrictionExceptions.ALLOW_ACCESS);
+            db.insert(Tables.RESTRICTION_EXCEPTIONS, null, values);
+        }
+
+    }
+
+    /**
+     * Remove a {@link RestrictionExceptions} record. This will update the
+     * in-memory lookup table, and write to the database when needed. Any
+     * callers should enforce that the {@link Binder#getCallingUid()} has the
+     * authority to revoke exceptions.
+     */
+    public void removeRestrictionException(Context context, ContentValues values) {
+        final PackageManager pm = context.getPackageManager();
+        final SQLiteDatabase db = this.getWritableDatabase();
+
+        // Read incoming package values and find lookup values
+        final String packageProvider = values.getAsString(RestrictionExceptions.PACKAGE_PROVIDER);
+        final String packageClient = values.getAsString(RestrictionExceptions.PACKAGE_CLIENT);
+        final long packageProviderId = getPackageId(packageProvider);
+        final long packageClientId = getPackageId(packageClient);
+
+        // Find the client UID to update our internal lookup table and remove
+        // the exception from our database if we changed the in-memory cache.
+        final int clientUid = getUidForPackageName(pm, packageClient);
+        final boolean cacheChanged = mCache.removeException(packageProviderId, clientUid);
+        if (cacheChanged) {
+            db.delete(Tables.RESTRICTION_EXCEPTIONS,
+                    RestrictionExceptionsColumns.PACKAGE_PROVIDER_ID + "=" + packageProviderId
+                            + " AND " + RestrictionExceptionsColumns.PACKAGE_CLIENT_ID + "="
+                            + packageClientId, null);
+        }
+
+    }
+
+    /**
+     * Return the exception clause that should be used when running {@link Data}
+     * queries that may be impacted by {@link Contacts#IS_RESTRICTED}. Will
+     * return a clause of all of the provider packages that have granted
+     * exceptions to the requested client UID.
+     */
+    public String getRestrictionExceptionClause(int clientUid, String column) {
+        return mCache.getExceptionQueryClause(clientUid, column);
+    }
+
+    /**
+     * Utility class to build a selection query clause that matches a specific
+     * column against any one of the contained values. You must provide any
+     * escaping of the field values yourself.
+     */
+    private static class MatchesClause<T> extends LinkedList<T> {
+        private final HashMap<String, String> mCache = new HashMap<String, String>();
+
+        private static final String JOIN_OR = " OR ";
+
+        public synchronized boolean addMatch(T object) {
+            mCache.clear();
+            return super.add(object);
+        }
+
+        public synchronized void removeMatch(T object) {
+            mCache.clear();
+            super.remove(object);
+        }
+
+        /**
+         * Return the query clause that would match the given column string to
+         * any values added through {@link #addMatch(Object)}.
+         */
+        public synchronized String getQueryClause(String column, StringBuilder recycle) {
+            // We maintain an internal cache for each requested column, and only
+            // build the actual value when needed.
+            String queryClause = mCache.get(column);
+            final int size = this.size();
+            if (queryClause == null && size > 0) {
+                recycle.setLength(0);
+                for (int i = 0; i < size; i++) {
+                    recycle.append(column);
+                    recycle.append("=");
+                    recycle.append(this.get(i));
+                    recycle.append(JOIN_OR);
+                }
+
+                // Trim off the last "OR" clause and store cached value.
+                final int length = recycle.length();
+                recycle.delete(length - JOIN_OR.length(), length);
+                queryClause = recycle.toString();
+                mCache.put(column, queryClause);
+            }
+            return queryClause;
+        }
+    }
+
+    /**
+     * Optimized in-memory cache for storing {@link RestrictionExceptions} that
+     * have been read up from database. Helper methods indicate when an
+     * exception change require writing to disk, and build query clauses for a
+     * specific {@link RestrictionExceptions#PACKAGE_CLIENT}.
+     */
+    private static class RestrictionExceptionsCache extends HashMap<Integer, MatchesClause<Long>> {
+        private final StringBuilder mBuilder = new StringBuilder();
+
+        private static final String[] PROJ_RESTRICTION_EXCEPTIONS = new String[] {
+                RestrictionExceptionsColumns.PACKAGE_PROVIDER_ID,
+                RestrictionExceptions.PACKAGE_CLIENT,
+        };
+
+        private static final int COL_PACKAGE_PROVIDER_ID = 0;
+        private static final int COL_PACKAGE_CLIENT = 1;
+
+        public void loadFromDatabase(Context context, SQLiteDatabase db) {
+            final PackageManager pm = context.getPackageManager();
+
+            // Load all existing exceptions from our database.
+            Cursor cursor = null;
+            try {
+                cursor = db.query(Tables.RESTRICTION_EXCEPTIONS, PROJ_RESTRICTION_EXCEPTIONS, null,
+                        null, null, null, null);
+                while (cursor.moveToNext()) {
+                    // Read provider and client package details from database
+                    final long packageProviderId = cursor.getLong(COL_PACKAGE_PROVIDER_ID);
+                    final String clientPackage = cursor.getString(COL_PACKAGE_CLIENT);
+
+                    try {
+                        // Create exception entry for this client
+                        final int clientUid = getUidForPackageName(pm, clientPackage);
+                        addException(packageProviderId, clientUid);
+                    } catch (RuntimeException e) {
+                        Log.w(TAG, "Failed to grant restriction exception to " + clientPackage);
+                        continue;
+                    }
+
+                }
+            } finally {
+                if (cursor != null) {
+                    cursor.close();
+                }
+            }
+        }
+
+        /**
+         * Lazily fetch a {@link MatchesClause} instance, creating a new one if
+         * both needed and requested.
+         */
+        private MatchesClause<Long> getLazy(int clientUid, boolean create) {
+            MatchesClause<Long> matchesClause = get(clientUid);
+            if (matchesClause == null && create) {
+                matchesClause = new MatchesClause<Long>();
+                put(clientUid, matchesClause);
+            }
+            return matchesClause;
+        }
+
+        /**
+         * Build a query clause that will allow the restriction exceptions
+         * granted to a specific {@link Binder#getCallingUid()}.
+         */
+        public String getExceptionQueryClause(int clientUid, String column) {
+            MatchesClause<Long> matchesClause = getLazy(clientUid, false);
+            if (matchesClause != null) {
+                return matchesClause.getQueryClause(column, mBuilder);
+            } else {
+                // When no matching clause found, return 0 to provide a false
+                // value for the query string.
+                return "0";
+            }
+        }
+
+        /**
+         * Add a {@link RestrictionExceptions} into the cache. Returns true if
+         * this action resulted in the cache being changed.
+         */
+        public boolean addException(long packageProviderId, int clientUid) {
+            MatchesClause<Long> matchesClause = getLazy(clientUid, true);
+            if (matchesClause.contains(packageProviderId)) {
+                return false;
+            } else {
+                matchesClause.addMatch(packageProviderId);
+                return true;
+            }
+        }
+
+        /**
+         * Remove a {@link RestrictionExceptions} from the cache. Returns true if
+         * this action resulted in the cache being changed.
+         */
+        public boolean removeException(long packageProviderId, int clientUid) {
+            MatchesClause<Long> matchesClause = getLazy(clientUid, false);
+            if (matchesClause == null || !matchesClause.contains(packageProviderId)) {
+                return false;
+            } else {
+                matchesClause.removeMatch(packageProviderId);
+                return true;
+            }
+        }
+    }
+
+}
diff --git a/src/com/android/providers/contacts/ReorderingCursorWrapper.java b/src/com/android/providers/contacts/ReorderingCursorWrapper.java
new file mode 100644
index 0000000..5d7983a
--- /dev/null
+++ b/src/com/android/providers/contacts/ReorderingCursorWrapper.java
@@ -0,0 +1,94 @@
+/*
+ * 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.contacts;
+
+import android.database.AbstractCursor;
+import android.database.Cursor;
+
+/**
+ * Cursor wrapper that reorders rows according to supplied specific position mapping.
+ */
+public class ReorderingCursorWrapper extends AbstractCursor {
+
+    private final Cursor mCursor;
+    private final int[] mPositionMap;
+
+    /**
+     * Constructor.
+     *
+     * @param cursor wrapped cursor
+     * @param positionMap maps wrapper cursor positions to wrapped cursor positions
+     *            so that positionMap[wrapperPosition] == wrappedPosition
+     */
+    public ReorderingCursorWrapper(Cursor cursor, int[] positionMap) {
+        if (cursor.getCount() != positionMap.length) {
+            throw new IllegalArgumentException("Cursor and position map have different sizes.");
+        }
+
+        mCursor = cursor;
+        mPositionMap = positionMap;
+    }
+
+    @Override
+    public boolean onMove(int oldPosition, int newPosition) {
+        return mCursor.moveToPosition(mPositionMap[newPosition]);
+    }
+
+    @Override
+    public String[] getColumnNames() {
+        return mCursor.getColumnNames();
+    }
+
+    @Override
+    public int getCount() {
+        return mCursor.getCount();
+    }
+
+    @Override
+    public double getDouble(int column) {
+        return mCursor.getDouble(column);
+    }
+
+    @Override
+    public float getFloat(int column) {
+        return mCursor.getFloat(column);
+    }
+
+    @Override
+    public int getInt(int column) {
+        return mCursor.getInt(column);
+    }
+
+    @Override
+    public long getLong(int column) {
+        return mCursor.getLong(column);
+    }
+
+    @Override
+    public short getShort(int column) {
+        return mCursor.getShort(column);
+    }
+
+    @Override
+    public String getString(int column) {
+        return mCursor.getString(column);
+    }
+
+    @Override
+    public boolean isNull(int column) {
+        return mCursor.isNull(column);
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/providers/contacts/SocialProvider.java b/src/com/android/providers/contacts/SocialProvider.java
new file mode 100644
index 0000000..50622b2
--- /dev/null
+++ b/src/com/android/providers/contacts/SocialProvider.java
@@ -0,0 +1,400 @@
+/*
+ * 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.contacts;
+
+import com.android.providers.contacts.OpenHelper.ActivitiesColumns;
+import com.android.providers.contacts.OpenHelper.Tables;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Aggregates;
+import android.provider.ContactsContract.Contacts;
+import android.provider.SocialContract;
+import android.provider.SocialContract.Activities;
+
+import android.net.Uri;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+/**
+ * Social activity content provider. The contract between this provider and
+ * applications is defined in {@link SocialContract}.
+ */
+public class SocialProvider extends ContentProvider {
+    // TODO: clean up debug tag
+    private static final String TAG = "SocialProvider ~~~~";
+
+    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final int ACTIVITIES = 1000;
+    private static final int ACTIVITIES_ID = 1001;
+    private static final int ACTIVITIES_AUTHORED_BY = 1002;
+
+    private static final int AGGREGATE_STATUS_ID = 3000;
+
+    private static final String DEFAULT_SORT_ORDER = Activities.THREAD_PUBLISHED + " DESC, "
+            + Activities.PUBLISHED + " ASC";
+
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sAggregatesProjectionMap;
+    /** Contains just the contacts columns */
+    private static final HashMap<String, String> sContactsProjectionMap;
+    /** Contains just the activities columns */
+    private static final HashMap<String, String> sActivitiesProjectionMap;
+
+    /** Contains the activities, contacts, and aggregates columns, for joined tables */
+    private static final HashMap<String, String> sActivitiesAggregatesProjectionMap;
+
+    static {
+        // Contacts URI matching table
+        final UriMatcher matcher = sUriMatcher;
+
+        matcher.addURI(SocialContract.AUTHORITY, "activities", ACTIVITIES);
+        matcher.addURI(SocialContract.AUTHORITY, "activities/#", ACTIVITIES_ID);
+        matcher.addURI(SocialContract.AUTHORITY, "activities/authored_by/#", ACTIVITIES_AUTHORED_BY);
+
+        matcher.addURI(SocialContract.AUTHORITY, "aggregate_status/#", AGGREGATE_STATUS_ID);
+
+        HashMap<String, String> columns;
+
+        // Aggregates projection map
+        columns = new HashMap<String, String>();
+        columns.put(Aggregates.DISPLAY_NAME, Aggregates.DISPLAY_NAME);
+        sAggregatesProjectionMap = columns;
+
+        // Contacts projection map
+        columns = new HashMap<String, String>();
+        columns.put(Contacts._ID, "contacts._id AS _id");
+        columns.put(Contacts.AGGREGATE_ID, Contacts.AGGREGATE_ID);
+        sContactsProjectionMap = columns;
+
+        // Activities projection map
+        columns = new HashMap<String, String>();
+        columns.put(Activities._ID, "activities._id AS _id");
+        columns.put(Activities.PACKAGE, Activities.PACKAGE);
+        columns.put(Activities.MIMETYPE, Activities.MIMETYPE);
+        columns.put(Activities.RAW_ID, Activities.RAW_ID);
+        columns.put(Activities.IN_REPLY_TO, Activities.IN_REPLY_TO);
+        columns.put(Activities.AUTHOR_CONTACT_ID, Activities.AUTHOR_CONTACT_ID);
+        columns.put(Activities.TARGET_CONTACT_ID, Activities.TARGET_CONTACT_ID);
+        columns.put(Activities.PUBLISHED, Activities.PUBLISHED);
+        columns.put(Activities.THREAD_PUBLISHED, Activities.THREAD_PUBLISHED);
+        columns.put(Activities.TITLE, Activities.TITLE);
+        columns.put(Activities.SUMMARY, Activities.SUMMARY);
+        columns.put(Activities.LINK, Activities.LINK);
+        columns.put(Activities.THUMBNAIL, Activities.THUMBNAIL);
+        sActivitiesProjectionMap = columns;
+
+        // Activities, contacts, and aggregates projection map for joins
+        columns = new HashMap<String, String>();
+        columns.putAll(sAggregatesProjectionMap);
+        columns.putAll(sContactsProjectionMap);
+        columns.putAll(sActivitiesProjectionMap); // Final _id will be from Activities
+        sActivitiesAggregatesProjectionMap = columns;
+
+    }
+
+    private OpenHelper mOpenHelper;
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean onCreate() {
+        final Context context = getContext();
+        mOpenHelper = OpenHelper.getInstance(context);
+
+        // TODO remove this, it's here to force opening the database on boot for testing
+        mOpenHelper.getReadableDatabase();
+
+        return true;
+    }
+
+    /**
+     * Called when a change has been made.
+     *
+     * @param uri the uri that the change was made to
+     */
+    private void onChange(Uri uri) {
+        getContext().getContentResolver().notifyChange(ContactsContract.AUTHORITY_URI, null);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isTemporary() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        final int match = sUriMatcher.match(uri);
+        long id = 0;
+        switch (match) {
+            case ACTIVITIES: {
+                id = insertActivity(values);
+                break;
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        final Uri result = ContentUris.withAppendedId(Activities.CONTENT_URI, id);
+        onChange(result);
+        return result;
+    }
+
+    /**
+     * Inserts an item into the {@link Tables#ACTIVITIES} table.
+     *
+     * @param values the values for the new row
+     * @return the row ID of the newly created row
+     */
+    private long insertActivity(ContentValues values) {
+
+        // TODO verify that IN_REPLY_TO != RAW_ID
+
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+        long id = 0;
+        db.beginTransaction();
+        try {
+            // TODO: Consider enforcing Binder.getCallingUid() for package name
+            // requested by this insert.
+
+            // Replace package name and mime-type with internal mappings
+            final String packageName = values.getAsString(Activities.PACKAGE);
+            values.put(ActivitiesColumns.PACKAGE_ID, mOpenHelper.getPackageId(packageName));
+            values.remove(Activities.PACKAGE);
+
+            final String mimeType = values.getAsString(Activities.MIMETYPE);
+            values.put(ActivitiesColumns.MIMETYPE_ID, mOpenHelper.getMimeTypeId(mimeType));
+            values.remove(Activities.MIMETYPE);
+
+            long published = values.getAsLong(Activities.PUBLISHED);
+            long threadPublished = published;
+
+            String inReplyTo = values.getAsString(Activities.IN_REPLY_TO);
+            if (inReplyTo != null) {
+                threadPublished = getThreadPublished(db, inReplyTo, published);
+            }
+
+            values.put(Activities.THREAD_PUBLISHED, threadPublished);
+
+            // Insert the data row itself
+            id = db.insert(Tables.ACTIVITIES, Activities.RAW_ID, values);
+
+            // Adjust thread timestamps on replies that have already been inserted
+            if (values.containsKey(Activities.RAW_ID)) {
+                adjustReplyTimestamps(db, values.getAsString(Activities.RAW_ID), published);
+            }
+
+            db.setTransactionSuccessful();
+        } finally {
+            db.endTransaction();
+        }
+        return id;
+    }
+
+    /**
+     * Finds the timestamp of the original message in the thread. If not found, returns
+     * {@code defaultValue}.
+     */
+    private long getThreadPublished(SQLiteDatabase db, String rawId, long defaultValue) {
+        String inReplyTo = null;
+        long threadPublished = defaultValue;
+
+        final Cursor c = db.query(Tables.ACTIVITIES,
+                new String[]{Activities.IN_REPLY_TO, Activities.PUBLISHED},
+                Activities.RAW_ID + " = ?", new String[]{rawId}, null, null, null);
+        try {
+            if (c.moveToFirst()) {
+                inReplyTo = c.getString(0);
+                threadPublished = c.getLong(1);
+            }
+        } finally {
+            c.close();
+        }
+
+        if (inReplyTo != null) {
+
+            // Call recursively to obtain the original timestamp of the entire thread
+            return getThreadPublished(db, inReplyTo, threadPublished);
+        }
+
+        return threadPublished;
+    }
+
+    /**
+     * In case the original message of a thread arrives after its reply messages, we need
+     * to check if there are any replies in the database and if so adjust their thread_published.
+     */
+    private void adjustReplyTimestamps(SQLiteDatabase db, String inReplyTo, long threadPublished) {
+
+        ContentValues values = new ContentValues();
+        values.put(Activities.THREAD_PUBLISHED, threadPublished);
+
+        /*
+         * Issuing an exploratory update. If it updates nothing, we are done.  Otherwise,
+         * we will run a query to find the updated records again and repeat recursively.
+         */
+        int replies = db.update(Tables.ACTIVITIES, values,
+                Activities.IN_REPLY_TO + "= ?", new String[] {inReplyTo});
+
+        if (replies == 0) {
+            return;
+        }
+
+        /*
+         * Presumably this code will be executed very infrequently since messages tend to arrive
+         * in the order they get sent.
+         */
+        ArrayList<String> rawIds = new ArrayList<String>(replies);
+        final Cursor c = db.query(Tables.ACTIVITIES,
+                new String[]{Activities.RAW_ID},
+                Activities.IN_REPLY_TO + " = ?", new String[] {inReplyTo}, null, null, null);
+        try {
+            while (c.moveToNext()) {
+                rawIds.add(c.getString(0));
+            }
+        } finally {
+            c.close();
+        }
+
+        for (String rawId : rawIds) {
+            adjustReplyTimestamps(db, rawId, threadPublished);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
+
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case ACTIVITIES_ID: {
+                final long activityId = ContentUris.parseId(uri);
+                return db.delete(Tables.ACTIVITIES, Activities._ID + "=" + activityId, null);
+            }
+
+            case ACTIVITIES_AUTHORED_BY: {
+                final long contactId = ContentUris.parseId(uri);
+                return db.delete(Tables.ACTIVITIES, Activities.AUTHOR_CONTACT_ID + "=" + contactId, null);
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+            String sortOrder) {
+        final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+        String limit = null;
+
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case ACTIVITIES: {
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
+                break;
+            }
+
+            case ACTIVITIES_ID: {
+                // TODO: enforce that caller has read access to this data
+                long activityId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
+                qb.appendWhere(Activities._ID + "=" + activityId);
+                break;
+            }
+
+            case ACTIVITIES_AUTHORED_BY: {
+                long contactId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
+                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + "=" + contactId);
+                break;
+            }
+
+            case AGGREGATE_STATUS_ID: {
+                long aggId = ContentUris.parseId(uri);
+                qb.setTables(Tables.ACTIVITIES_JOIN_MIMETYPES_CONTACTS_PACKAGES_AGGREGATES);
+                qb.setProjectionMap(sActivitiesAggregatesProjectionMap);
+
+                // Latest status of an aggregate is any top-level status
+                // authored by one of its children contacts.
+                qb.appendWhere(Activities.IN_REPLY_TO + " IS NULL AND ");
+                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + " IN (SELECT " + BaseColumns._ID
+                        + " FROM " + Tables.CONTACTS + " WHERE " + Contacts.AGGREGATE_ID + "="
+                        + aggId + ")");
+                sortOrder = Activities.PUBLISHED + " DESC";
+                limit = "1";
+                break;
+            }
+
+            default:
+                throw new UnsupportedOperationException("Unknown uri: " + uri);
+        }
+
+        // Default to reverse-chronological sort if nothing requested
+        if (sortOrder == null) {
+            sortOrder = DEFAULT_SORT_ORDER;
+        }
+
+        // Perform the query and set the notification uri
+        final Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, limit);
+        if (c != null) {
+            c.setNotificationUri(getContext().getContentResolver(), ContactsContract.AUTHORITY_URI);
+        }
+        return c;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        final int match = sUriMatcher.match(uri);
+        switch (match) {
+            case ACTIVITIES:
+            case ACTIVITIES_AUTHORED_BY:
+                return Activities.CONTENT_TYPE;
+            case ACTIVITIES_ID:
+                final SQLiteDatabase db = mOpenHelper.getReadableDatabase();
+                long activityId = ContentUris.parseId(uri);
+                return mOpenHelper.getActivityMimeType(activityId);
+            case AGGREGATE_STATUS_ID:
+                return Aggregates.CONTENT_ITEM_TYPE;
+        }
+        throw new UnsupportedOperationException("Unknown uri: " + uri);
+    }
+}
diff --git a/tests/Android.mk b/tests/Android.mk
new file mode 100644
index 0000000..96ae80f
--- /dev/null
+++ b/tests/Android.mk
@@ -0,0 +1,17 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+# We only want this apk build for tests.
+LOCAL_MODULE_TAGS := tests
+
+# Only compile source java files in this apk.
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := ContactsProvider2Tests
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+LOCAL_INSTRUMENTATION_FOR := ContactsProvider
+LOCAL_CERTIFICATE := shared
+
+include $(BUILD_PACKAGE)
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 0000000..d662b53
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2008 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+        package="com.android.providers.contacts.tests"
+        android:sharedUserId="android.uid.shared">
+
+    <uses-permission android:name="android.permission.READ_CONTACTS" />
+    <uses-permission android:name="android.permission.WRITE_CONTACTS" />
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <!--
+    The test delcared in this instrumentation will be run along with tests declared by
+    all other applications via the command: "adb shell itr".
+    The "itr" command will find all tests declared by all applications. If you want to run just these
+    tests on their own then use the command:
+    "adb shell am instrument -w com.android.providers.contacts.tests/android.test.InstrumentationTestRunner"
+    -->
+    <instrumentation android:name="android.test.InstrumentationTestRunner"
+        android:targetPackage="com.android.providers.contacts"
+        android:label="Contacts2 provider tests">
+    </instrumentation>
+
+</manifest>
diff --git a/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java b/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
new file mode 100644
index 0000000..aa2b2ef
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/BaseContactsProvider2Test.java
@@ -0,0 +1,228 @@
+/*
+ * 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.contacts;
+
+import static com.android.providers.contacts.ContactsActor.PACKAGE_GREY;
+
+import com.android.providers.contacts.ContactsActor;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.Aggregates;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Nickname;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.test.AndroidTestCase;
+import android.test.mock.MockContentResolver;
+import android.test.suitebuilder.annotation.LargeTest;
+
+/**
+ * A common superclass for {@link ContactsProvider2}-related tests.
+ */
+@LargeTest
+public abstract class BaseContactsProvider2Test extends AndroidTestCase {
+
+    protected static final String PACKAGE = "ContactsProvider2Test";
+
+    private ContactsActor mActor;
+    protected MockContentResolver mResolver;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mActor = new ContactsActor(getContext(), PACKAGE_GREY);
+        mResolver = mActor.resolver;
+    }
+
+    protected long createContact() {
+        ContentValues values = new ContentValues();
+        values.put(Contacts.PACKAGE, mActor.packageName);
+        Uri contactUri = mResolver.insert(Contacts.CONTENT_URI, values);
+        return ContentUris.parseId(contactUri);
+    }
+
+    protected Uri insertStructuredName(long contactId, String givenName, String familyName) {
+        ContentValues values = new ContentValues();
+        StringBuilder sb = new StringBuilder();
+        if (givenName != null) {
+            sb.append(givenName);
+        }
+        if (givenName != null && familyName != null) {
+            sb.append(" ");
+        }
+        if (familyName != null) {
+            sb.append(familyName);
+        }
+        values.put(StructuredName.DISPLAY_NAME, sb.toString());
+        values.put(StructuredName.GIVEN_NAME, givenName);
+        values.put(StructuredName.FAMILY_NAME, familyName);
+
+        return insertStructuredName(contactId, values);
+    }
+
+    protected Uri insertStructuredName(long contactId, ContentValues values) {
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertPhoneNumber(long contactId, String phoneNumber) {
+        ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+        values.put(Phone.NUMBER, phoneNumber);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertEmail(long contactId, String email) {
+        ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE);
+        values.put(Email.DATA, email);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertNickname(long contactId, String nickname) {
+        ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, Nickname.CONTENT_ITEM_TYPE);
+        values.put(Nickname.NAME, nickname);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertPresence(int protocol, String handle, int presence) {
+        ContentValues values = new ContentValues();
+        values.put(Presence.IM_PROTOCOL, protocol);
+        values.put(Presence.IM_HANDLE, handle);
+        values.put(Presence.PRESENCE_STATUS, presence);
+
+        Uri resultUri = mResolver.insert(Presence.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected Uri insertImHandle(long contactId, int protocol, String handle) {
+        ContentValues values = new ContentValues();
+        values.put(Data.CONTACT_ID, contactId);
+        values.put(Data.MIMETYPE, Im.CONTENT_ITEM_TYPE);
+        values.put(Im.PROTOCOL, protocol);
+        values.put(Im.DATA, handle);
+
+        Uri resultUri = mResolver.insert(Data.CONTENT_URI, values);
+        return resultUri;
+    }
+
+    protected void setAggregationException(int type, long aggregateId, long contactId) {
+        ContentValues values = new ContentValues();
+        values.put(AggregationExceptions.AGGREGATE_ID, aggregateId);
+        values.put(AggregationExceptions.CONTACT_ID, contactId);
+        values.put(AggregationExceptions.TYPE, type);
+        mResolver.update(AggregationExceptions.CONTENT_URI, values, null, null);
+    }
+
+    protected Cursor queryContact(long contactId) {
+        return mResolver.query(ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId), null,
+                null, null, null);
+    }
+
+    protected Cursor queryAggregate(long aggregateId) {
+        return mResolver.query(ContentUris.withAppendedId(Aggregates.CONTENT_URI, aggregateId),
+                null, null, null, null);
+    }
+
+    protected Cursor queryAggregateSummary(long aggregateId, String[] projection) {
+        return mResolver.query(ContentUris.withAppendedId(Aggregates.CONTENT_SUMMARY_URI,
+                aggregateId), projection, null, null, null);
+    }
+
+    protected Cursor queryAggregateSummary() {
+        return mResolver.query(Aggregates.CONTENT_SUMMARY_URI, null, null, null, null);
+    }
+
+    protected long queryAggregateId(long contactId) {
+        Cursor c = queryContact(contactId);
+        assertTrue(c.moveToFirst());
+        long aggregateId = c.getLong(c.getColumnIndex(Contacts.AGGREGATE_ID));
+        c.close();
+        return aggregateId;
+    }
+
+    protected String queryDisplayName(long aggregateId) {
+        Cursor c = queryAggregate(aggregateId);
+        assertTrue(c.moveToFirst());
+        String displayName = c.getString(c.getColumnIndex(Aggregates.DISPLAY_NAME));
+        c.close();
+        return displayName;
+    }
+
+    protected void assertAggregated(long contactId1, long contactId2) {
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 == aggregateId2);
+    }
+
+    protected void assertAggregated(long contactId1, long contactId2, String expectedDisplayName) {
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 == aggregateId2);
+
+        String displayName = queryDisplayName(aggregateId1);
+        assertEquals(expectedDisplayName, displayName);
+    }
+
+    protected void assertNotAggregated(long contactId1, long contactId2) {
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 != aggregateId2);
+    }
+
+    protected void assertStructuredName(long contactId, String prefix, String givenName,
+            String middleName, String familyName, String suffix) {
+        Uri uri = Uri.withAppendedPath(ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId),
+                Contacts.Data.CONTENT_DIRECTORY);
+
+        final String[] projection = new String[] {
+                StructuredName.PREFIX, StructuredName.GIVEN_NAME, StructuredName.MIDDLE_NAME,
+                StructuredName.FAMILY_NAME, StructuredName.SUFFIX
+        };
+
+        Cursor c = mResolver.query(uri, projection, Data.MIMETYPE + "='"
+                + StructuredName.CONTENT_ITEM_TYPE + "'", null, null);
+
+        assertTrue(c.moveToFirst());
+        assertEquals(prefix, c.getString(0));
+        assertEquals(givenName, c.getString(1));
+        assertEquals(middleName, c.getString(2));
+        assertEquals(familyName, c.getString(3));
+        assertEquals(suffix, c.getString(4));
+        c.close();
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/ContactAggregationSchedulerTest.java b/tests/src/com/android/providers/contacts/ContactAggregationSchedulerTest.java
new file mode 100644
index 0000000..1a98d2a
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactAggregationSchedulerTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.contacts;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+import junit.framework.TestCase;
+
+/**
+ * Tests from {@link ContactAggregationScheduler}.
+ */
+@SmallTest
+public class ContactAggregationSchedulerTest extends TestCase {
+
+    private TestContactAggregationScheduler mScheduler;
+
+    @Override
+    protected void setUp() throws Exception {
+        mScheduler = new TestContactAggregationScheduler();
+    }
+
+    public void testScheduleInitial() {
+        mScheduler.schedule();
+        assertEquals(1, mScheduler.mRunDelayed);
+    }
+
+    public void testScheduleTwiceRapidly() {
+        mScheduler.schedule();
+
+        mScheduler.mTime += ContactAggregationScheduler.AGGREGATION_DELAY / 2;
+        mScheduler.schedule();
+        assertEquals(2, mScheduler.mRunDelayed);
+    }
+
+    public void testScheduleTwiceExceedingMaxDelay() {
+        mScheduler.schedule();
+
+        mScheduler.mTime += ContactAggregationScheduler.MAX_AGGREGATION_DELAY + 100;
+        mScheduler.schedule();
+        assertEquals(1, mScheduler.mRunDelayed);
+    }
+
+    public void testScheduleWhileRunning() {
+        mScheduler.setAggregator(new ContactAggregationScheduler.Aggregator() {
+            boolean mInterruptCalled;
+
+            public void interrupt() {
+                mInterruptCalled = true;
+            }
+
+            public void run() {
+                mScheduler.schedule();
+                assertTrue(mInterruptCalled);
+            }
+        });
+
+        mScheduler.run();
+        assertEquals(1, mScheduler.mRunDelayed);
+    }
+
+    public void testScheduleWhileRunningExceedingMaxDelay() {
+        mScheduler.schedule();
+
+        mScheduler.mTime += ContactAggregationScheduler.MAX_AGGREGATION_DELAY + 100;
+
+        mScheduler.setAggregator(new ContactAggregationScheduler.Aggregator() {
+            boolean mInterruptCalled;
+
+            public void interrupt() {
+                mInterruptCalled = true;
+            }
+
+            public void run() {
+                mScheduler.schedule();
+                assertFalse(mInterruptCalled);
+            }
+        });
+
+        mScheduler.run();
+        assertEquals(2, mScheduler.mRunDelayed);
+    }
+
+    private static class TestContactAggregationScheduler extends ContactAggregationScheduler {
+
+        long mTime = 1000;
+        int mRunDelayed;
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        long currentTime() {
+            return mTime;
+        }
+
+        @Override
+        void runDelayed() {
+            mRunDelayed++;
+        }
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/ContactAggregatorTest.java b/tests/src/com/android/providers/contacts/ContactAggregatorTest.java
new file mode 100644
index 0000000..daff7f0
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactAggregatorTest.java
@@ -0,0 +1,544 @@
+/*
+ * 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.contacts;
+
+import android.content.ContentUris;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.Aggregates;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.test.suitebuilder.annotation.LargeTest;
+
+/**
+ * Unit tests for {@link ContactAggregator}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class ContactAggregatorTest extends BaseContactsProvider2Test {
+
+    private static final String[] AGGREGATION_EXCEPTION_PROJECTION = new String[] {
+            AggregationExceptions.TYPE,
+            AggregationExceptions.AGGREGATE_ID,
+            AggregationExceptions.CONTACT_ID
+    };
+
+    public void testCrudAggregationExceptions() throws Exception {
+        long contactId1 = createContact();
+        long aggregateId = queryAggregateId(contactId1);
+        long contactId2 = createContact();
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, aggregateId, contactId2);
+
+        // Refetch the row we have just inserted
+        Cursor c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.AGGREGATE_ID + "="
+                        + aggregateId, null, null);
+
+        assertTrue(c.moveToFirst());
+        assertEquals(AggregationExceptions.TYPE_KEEP_IN, c.getInt(0));
+        assertEquals(aggregateId, c.getLong(1));
+        assertEquals(contactId2, c.getLong(2));
+        assertFalse(c.moveToNext());
+        c.close();
+
+        // Change from TYPE_KEEP_IN to TYPE_KEEP_OUT
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, aggregateId, contactId2);
+
+        c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.AGGREGATE_ID + "="
+                        + aggregateId, null, null);
+
+        assertTrue(c.moveToFirst());
+        assertEquals(AggregationExceptions.TYPE_KEEP_OUT, c.getInt(0));
+        assertEquals(aggregateId, c.getLong(1));
+        assertEquals(contactId2, c.getLong(2));
+        assertFalse(c.moveToNext());
+        c.close();
+
+        // Delete the rule
+        setAggregationException(AggregationExceptions.TYPE_AUTOMATIC, aggregateId, contactId2);
+
+        // Verify that the row is gone
+        c = mResolver.query(AggregationExceptions.CONTENT_URI,
+                AGGREGATION_EXCEPTION_PROJECTION, AggregationExceptions.AGGREGATE_ID + "="
+                        + aggregateId, null, null);
+        assertFalse(c.moveToFirst());
+        c.close();
+    }
+
+    public void testAggregationCreatesNewAggregate() {
+        long contactId = createContact();
+
+        Uri resultUri = insertStructuredName(contactId, "Johna", "Smitha");
+
+        // Parse the URI and confirm that it contains an ID
+        assertTrue(ContentUris.parseId(resultUri) != 0);
+
+        long aggregateId = queryAggregateId(contactId);
+        assertTrue(aggregateId != 0);
+
+        String displayName = queryDisplayName(aggregateId);
+        assertEquals("Johna Smitha", displayName);
+    }
+
+    public void testAggregationOfExactFullNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnb", "Smithb");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnb", "Smithb");
+
+        assertAggregated(contactId1, contactId2, "Johnb Smithb");
+    }
+
+    public void testAggregationOfCaseInsensitiveFullNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnc", "Smithc");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnc", "smithc");
+
+        assertAggregated(contactId1, contactId2, "Johnc Smithc");
+    }
+
+    public void testAggregationOfLastNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, null, "Johnd");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, null, "johnd");
+
+        assertAggregated(contactId1, contactId2, "Johnd");
+    }
+
+    public void testNonAggregationOfFirstNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johne", "Smithe");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johne", null);
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    // TODO: should this be allowed to match?
+    public void testNonAggregationOfLastNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnf", "Smithf");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, null, "Smithf");
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationOfConcatenatedFullNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johng", "Smithg");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "johngsmithg", null);
+
+        assertAggregated(contactId1, contactId2, "Johng Smithg");
+    }
+
+    public void testAggregationOfNormalizedFullNameMatch() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "H\u00e9l\u00e8ne", "Bj\u00f8rn");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "helene bjorn", null);
+
+        assertAggregated(contactId1, contactId2, "H\u00e9l\u00e8ne Bj\u00f8rn");
+    }
+
+    public void testAggregationBasedOnPhoneNumberNoNameData() {
+        long contactId1 = createContact();
+        insertPhoneNumber(contactId1, "(888)555-1231");
+
+        long contactId2 = createContact();
+        insertPhoneNumber(contactId2, "1(888)555-1231");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWhenTargetAggregateHasNoName() {
+        long contactId1 = createContact();
+        insertPhoneNumber(contactId1, "(888)555-1232");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnl", "Smithl");
+        insertPhoneNumber(contactId2, "1(888)555-1232");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWhenNewContactHasNoName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnm", "Smithm");
+        insertPhoneNumber(contactId1, "(888)555-1233");
+
+        long contactId2 = createContact();
+        insertPhoneNumber(contactId2, "1(888)555-1233");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWithSimilarNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Ogre", "Hunter");
+        insertPhoneNumber(contactId1, "(888)555-1234");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Opra", "Humper");
+        insertPhoneNumber(contactId2, "1(888)555-1234");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnPhoneNumberWithDifferentNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Baby", "Bear");
+        insertPhoneNumber(contactId1, "(888)555-1235");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Blind", "Mouse");
+        insertPhoneNumber(contactId2, "1(888)555-1235");
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnEmailNoNameData() {
+        long contactId1 = createContact();
+        insertEmail(contactId1, "lightning@android.com");
+
+        long contactId2 = createContact();
+        insertEmail(contactId2, "lightning@android.com");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnEmailWhenTargetAggregateHasNoName() {
+        long contactId1 = createContact();
+        insertEmail(contactId1, "mcqueen@android.com");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Lightning", "McQueen");
+        insertEmail(contactId2, "mcqueen@android.com");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnEmailWhenNewContactHasNoName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Doc", "Hudson");
+        insertEmail(contactId1, "doc@android.com");
+
+        long contactId2 = createContact();
+        insertEmail(contactId2, "doc@android.com");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnEmailWithSimilarNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Sally", "Carrera");
+        insertEmail(contactId1, "sally@android.com");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Sallie", "Carerra");
+        insertEmail(contactId2, "sally@android.com");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationBasedOnEmailWithDifferentNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Chick", "Hicks");
+        insertEmail(contactId1, "hicky@android.com");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Luigi", "Guido");
+        insertEmail(contactId2, "hicky@android.com");
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationByCommonNicknameWithLastName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Bill", "Gore");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "William", "Gore");
+
+        assertAggregated(contactId1, contactId2, "William Gore");
+    }
+
+    public void testAggregationByCommonNicknameOnly() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Lawrence", null);
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Larry", null);
+
+        assertAggregated(contactId1, contactId2, "Lawrence");
+    }
+
+    public void testAggregationByNicknameNoStructuredName() {
+        long contactId1 = createContact();
+        insertNickname(contactId1, "Frozone");
+
+        long contactId2 = createContact();
+        insertNickname(contactId2, "Frozone");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationByNicknameWithSimilarNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Buddy", "Pine");
+        insertNickname(contactId1, "Syndrome");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Body", "Pane");
+        insertNickname(contactId2, "Syndrome");
+
+        assertAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationByNicknameWithDifferentNames() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Helen", "Parr");
+        insertNickname(contactId1, "Elastigirl");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Shawn", "Johnson");
+        insertNickname(contactId2, "Elastigirl");
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationExceptionKeepIn() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnk", "Smithk");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnkx", "Smithkx");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN,
+                queryAggregateId(contactId1), contactId2);
+
+        assertAggregated(contactId1, contactId2, "Johnkx Smithkx");
+
+        // Assert that the empty aggregate got removed
+        long newAggregateId1 = queryAggregateId(contactId1);
+        if (aggregateId1 != newAggregateId1) {
+            Cursor cursor = queryAggregate(aggregateId1);
+            assertFalse(cursor.moveToFirst());
+            cursor.close();
+        } else {
+            Cursor cursor = queryAggregate(aggregateId2);
+            assertFalse(cursor.moveToFirst());
+            cursor.close();
+        }
+    }
+
+    public void testAggregationExceptionKeepOut() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johnh", "Smithh");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnh", "Smithh");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryAggregateId(contactId1), contactId2);
+
+        assertNotAggregated(contactId1, contactId2);
+    }
+
+    public void testAggregationExceptionKeepOutCheckUpdatesDisplayName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Johni", "Smithi");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Johnj", "Smithj");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN,
+                queryAggregateId(contactId1), contactId2);
+
+        assertAggregated(contactId1, contactId2, "Johnj Smithj");
+
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryAggregateId(contactId1), contactId2);
+
+        assertNotAggregated(contactId1, contactId2);
+
+        String displayName1 = queryDisplayName(queryAggregateId(contactId1));
+        assertEquals("Johni Smithi", displayName1);
+
+        String displayName2 = queryDisplayName(queryAggregateId(contactId2));
+        assertEquals("Johnj Smithj", displayName2);
+    }
+
+    public void testAggregationSuggestionsBasedOnName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Duane", null);
+
+        // Exact name match
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Duane", null);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT,
+                queryAggregateId(contactId1), contactId2);
+
+        // Edit distance == 0.84
+        long contactId3 = createContact();
+        insertStructuredName(contactId3, "Dwayne", null);
+
+        // Edit distance == 0.6
+        long contactId4 = createContact();
+        insertStructuredName(contactId4, "Donny", null);
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        long aggregateId3 = queryAggregateId(contactId3);
+
+        assertSuggestions(aggregateId1, aggregateId2, aggregateId3);
+    }
+
+    public void testAggregationSuggestionsBasedOnPhoneNumber() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Lord", "Farquaad");
+        insertPhoneNumber(contactId1, "(888)555-1236");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Talking", "Donkey");
+        insertPhoneNumber(contactId2, "1(888)555-1236");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 != aggregateId2);
+
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnEmailAddress() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Carl", "Fredricksen");
+        insertEmail(contactId1, "up@android.com");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Charles", "Muntz");
+        insertEmail(contactId2, "up@android.com");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 != aggregateId2);
+
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnEmailAddressApproximateMatch() {
+
+        // Create two contacts that would not be aggregated because of name mismatch
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Bob", null);
+        insertEmail(contactId1, "incredible2004@android.com");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Lucius", "Best");
+        insertEmail(contactId2, "incrediball@androidd.com");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertTrue(aggregateId1 != aggregateId2);
+
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnNickname() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Peter", "Parker");
+        insertNickname(contactId1, "Spider-Man");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Manny", "Spider");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, aggregateId1, contactId2);
+
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnNicknameMatchingName() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Clark", "Kent");
+        insertNickname(contactId1, "Superman");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Roy", "Williams");
+        insertNickname(contactId2, "superman");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, aggregateId1, contactId2);
+
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    public void testAggregationSuggestionsBasedOnCommonNickname() {
+        long contactId1 = createContact();
+        insertStructuredName(contactId1, "Dick", "Cherry");
+
+        long contactId2 = createContact();
+        insertStructuredName(contactId2, "Richard", "Cherry");
+
+        long aggregateId1 = queryAggregateId(contactId1);
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, aggregateId1, contactId2);
+
+        long aggregateId2 = queryAggregateId(contactId2);
+        assertSuggestions(aggregateId1, aggregateId2);
+    }
+
+    private void assertSuggestions(long aggregateId, long... suggestions) {
+        final Uri aggregateUri = ContentUris.withAppendedId(Aggregates.CONTENT_URI, aggregateId);
+        Uri uri = Uri.withAppendedPath(aggregateUri,
+                Aggregates.AggregationSuggestions.CONTENT_DIRECTORY);
+        final Cursor cursor = mResolver.query(uri, new String[] { Aggregates._ID },
+                null, null, null);
+
+        assertEquals(suggestions.length, cursor.getCount());
+
+        for (int i = 0; i < suggestions.length; i++) {
+            cursor.moveToNext();
+            assertEquals(suggestions[i], cursor.getLong(0));
+        }
+
+        cursor.close();
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/ContactsActor.java b/tests/src/com/android/providers/contacts/ContactsActor.java
new file mode 100644
index 0000000..5936f8f
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactsActor.java
@@ -0,0 +1,296 @@
+/*
+ * 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.contacts;
+
+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;
+import android.test.mock.MockContext;
+import android.test.mock.MockPackageManager;
+import android.util.Log;
+
+import java.util.HashMap;
+
+/**
+ * Helper class that encapsulates an "actor" which is owned by a specific
+ * package name. It correctly maintains a wrapped {@link Context} and an
+ * attached {@link MockContentResolver}. Multiple actors can be used to test
+ * security scenarios between multiple packages.
+ */
+public class ContactsActor {
+    private static final String FILENAME_PREFIX = "test.";
+
+    public static final String PACKAGE_GREY = "edu.example.grey";
+    public static final String PACKAGE_RED = "net.example.red";
+    public static final String PACKAGE_GREEN = "com.example.green";
+    public static final String PACKAGE_BLUE = "org.example.blue";
+
+    public Context context;
+    public String packageName;
+    public MockContentResolver resolver;
+    public SynchronousContactsProvider2 provider;
+
+    /**
+     * Create an "actor" using the given parent {@link Context} and the specific
+     * package name. Internally, all {@link Context} method calls are passed to
+     * a new instance of {@link RestrictionMockContext}, which stubs out the
+     * security infrastructure.
+     */
+    public ContactsActor(Context overallContext, String packageName) {
+        context = new RestrictionMockContext(overallContext, packageName);
+        this.packageName = packageName;
+        resolver = new MockContentResolver();
+
+        RenamingDelegatingContext targetContextWrapper = new RenamingDelegatingContext(
+                context, overallContext, FILENAME_PREFIX);
+        Context providerContext = new IsolatedContext(resolver, targetContextWrapper);
+
+        provider = new SynchronousContactsProvider2();
+        provider.attachInfo(providerContext, null);
+        resolver.addProvider(ContactsContract.AUTHORITY, provider);
+    }
+
+    /**
+     * Mock {@link Context} that reports specific well-known values for testing
+     * data protection. The creator can override the owner package name, and
+     * force the {@link PackageManager} to always return a well-known package
+     * list for any call to {@link PackageManager#getPackagesForUid(int)}.
+     * <p>
+     * For example, the creator could request that the {@link Context} lives in
+     * package name "com.example.red", and also cause the {@link PackageManager}
+     * to report that no UID contains that package name.
+     */
+    private static class RestrictionMockContext extends MockContext {
+        private final Context mOverallContext;
+        private final String mReportedPackageName;
+        private final RestrictionMockPackageManager mPackageManager;
+
+        /**
+         * Create a {@link Context} under the given package name.
+         */
+        public RestrictionMockContext(Context overallContext, String reportedPackageName) {
+            mOverallContext = overallContext;
+            mReportedPackageName = reportedPackageName;
+            mPackageManager = new RestrictionMockPackageManager();
+            mPackageManager.addPackage(1000, PACKAGE_GREY);
+            mPackageManager.addPackage(2000, PACKAGE_RED);
+            mPackageManager.addPackage(3000, PACKAGE_GREEN);
+            mPackageManager.addPackage(4000, PACKAGE_BLUE);
+        }
+
+        @Override
+        public String getPackageName() {
+            return mReportedPackageName;
+        }
+
+        @Override
+        public PackageManager getPackageManager() {
+            return mPackageManager;
+        }
+
+        @Override
+        public Resources getResources() {
+            return mOverallContext.getResources();
+        }
+    }
+
+    /**
+     * Mock {@link PackageManager} that knows about a specific set of packages
+     * to help test security models. Because {@link Binder#getCallingUid()}
+     * can't be mocked, you'll have to find your mock-UID manually using your
+     * {@link Context#getPackageName()}.
+     */
+    private static class RestrictionMockPackageManager extends MockPackageManager {
+        private final HashMap<Integer, String> mForward = new HashMap<Integer, String>();
+        private final HashMap<String, Integer> mReverse = new HashMap<String, Integer>();
+
+        /**
+         * Add a UID-to-package mapping, which is then stored internally.
+         */
+        public void addPackage(int packageUid, String packageName) {
+            mForward.put(packageUid, packageName);
+            mReverse.put(packageName, packageUid);
+        }
+
+        @Override
+        public String[] getPackagesForUid(int uid) {
+            return new String[] { mForward.get(uid) };
+        }
+
+        @Override
+        public ApplicationInfo getApplicationInfo(String packageName, int flags) {
+            ApplicationInfo info = new ApplicationInfo();
+            Integer uid = mReverse.get(packageName);
+            info.uid = (uid != null) ? uid : -1;
+            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/contacts/ContactsProvider2Test.java b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
new file mode 100644
index 0000000..fc7beb3
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/ContactsProvider2Test.java
@@ -0,0 +1,183 @@
+/*
+ * 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.contacts;
+
+import com.android.internal.util.ArrayUtils;
+import com.android.providers.contacts.BaseContactsProvider2Test;
+
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract.Aggregates;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Presence;
+import android.provider.ContactsContract.CommonDataKinds.Im;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.test.suitebuilder.annotation.LargeTest;
+import android.util.Log;
+
+/**
+ * Unit tests for {@link ContactsProvider2}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class ContactsProvider2Test extends BaseContactsProvider2Test {
+
+    public void testDisplayNameParsingWhenPartsUnspecified() {
+        long contactId = createContact();
+        ContentValues values = new ContentValues();
+        values.put(StructuredName.DISPLAY_NAME, "Mr.John Kevin von Smith, Jr.");
+        insertStructuredName(contactId, values);
+
+        assertStructuredName(contactId, "Mr", "John", "Kevin", "von Smith", "Jr");
+    }
+
+    public void testDisplayNameParsingWhenPartsSpecified() {
+        long contactId = createContact();
+        ContentValues values = new ContentValues();
+        values.put(StructuredName.DISPLAY_NAME, "Mr.John Kevin von Smith, Jr.");
+        values.put(StructuredName.FAMILY_NAME, "Johnson");
+        insertStructuredName(contactId, values);
+
+        assertStructuredName(contactId, null, null, null, "Johnson", null);
+    }
+
+    public void testSendToVoicemailDefault() {
+        long contactId = createContact();
+        long aggregateId = queryAggregateId(contactId);
+
+        Cursor c = queryAggregate(aggregateId);
+        assertTrue(c.moveToNext());
+        int sendToVoicemail = c.getInt(c.getColumnIndex(Aggregates.SEND_TO_VOICEMAIL));
+        assertEquals(0, sendToVoicemail);
+        c.close();
+    }
+
+    public void testSetSendToVoicemailAndRingtone() {
+        long contactId = createContact();
+        long aggregateId = queryAggregateId(contactId);
+
+        updateSendToVoicemailAndRingtone(aggregateId, true, "foo");
+        assertSendToVoicemailAndRingtone(aggregateId, true, "foo");
+    }
+
+    public void testSendToVoicemailAndRingtoneAfterAggregation() {
+        long contactId1 = createContact();
+        long aggregateId1 = queryAggregateId(contactId1);
+        updateSendToVoicemailAndRingtone(aggregateId1, true, "foo");
+
+        long contactId2 = createContact();
+        long aggregateId2 = queryAggregateId(contactId2);
+        updateSendToVoicemailAndRingtone(aggregateId2, true, "bar");
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, aggregateId1, contactId2);
+
+        // Both contacts had "send to VM", the aggregate now has the same value
+        assertSendToVoicemailAndRingtone(aggregateId1, true, "foo,bar"); // Either foo or bar
+    }
+
+    public void testDoNotSendToVoicemailAfterAggregation() {
+        long contactId1 = createContact();
+        long aggregateId1 = queryAggregateId(contactId1);
+        updateSendToVoicemailAndRingtone(aggregateId1, true, null);
+
+        long contactId2 = createContact();
+        long aggregateId2 = queryAggregateId(contactId2);
+        updateSendToVoicemailAndRingtone(aggregateId2, false, null);
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, aggregateId1, contactId2);
+
+        // Since one of the contacts had "don't send to VM" that setting wins for the aggregate
+        assertSendToVoicemailAndRingtone(aggregateId1, false, null);
+    }
+
+    public void testSetSendToVoicemailAndRingtonePreservedAfterJoinAndSplit() {
+        long contactId1 = createContact();
+        long aggregateId1 = queryAggregateId(contactId1);
+        updateSendToVoicemailAndRingtone(aggregateId1, true, "foo");
+
+        long contactId2 = createContact();
+        long aggregateId2 = queryAggregateId(contactId2);
+        updateSendToVoicemailAndRingtone(aggregateId2, false, "bar");
+
+        // Aggregate them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_IN, aggregateId1, contactId2);
+
+        // Split them
+        setAggregationException(AggregationExceptions.TYPE_KEEP_OUT, aggregateId1, contactId2);
+
+        assertSendToVoicemailAndRingtone(aggregateId1, true, "foo");
+        assertSendToVoicemailAndRingtone(queryAggregateId(contactId2), false, "bar");
+    }
+
+    public void testSinglePresenceRowPerAggregate() {
+        int protocol1 = Im.PROTOCOL_GOOGLE_TALK;
+        String handle1 = "test@gmail.com";
+
+        long contactId1 = createContact();
+        insertImHandle(contactId1, protocol1, handle1);
+
+        insertPresence(protocol1, handle1, Presence.AVAILABLE);
+        insertPresence(protocol1, handle1, Presence.AWAY);
+        insertPresence(protocol1, handle1, Presence.INVISIBLE);
+
+        Cursor c = queryAggregateSummary(queryAggregateId(contactId1),
+                new String[] {Presence.PRESENCE_STATUS});
+        assertEquals(c.getCount(), 1);
+
+        c.moveToFirst();
+        assertEquals(c.getInt(0), Presence.AVAILABLE);
+
+    }
+
+    private void updateSendToVoicemailAndRingtone(long aggregateId, boolean sendToVoicemail,
+            String ringtone) {
+        ContentValues values = new ContentValues();
+        values.put(Aggregates.SEND_TO_VOICEMAIL, sendToVoicemail);
+        if (ringtone != null) {
+            values.put(Aggregates.CUSTOM_RINGTONE, ringtone);
+        }
+
+        final Uri uri = ContentUris.withAppendedId(Aggregates.CONTENT_URI, aggregateId);
+        int count = mResolver.update(uri, values, null, null);
+        assertEquals(1, count);
+    }
+
+    private void assertSendToVoicemailAndRingtone(long aggregateId, boolean expectedSendToVoicemail,
+            String expectedRingtone) {
+        Cursor c = queryAggregate(aggregateId);
+        assertTrue(c.moveToNext());
+        int sendToVoicemail = c.getInt(c.getColumnIndex(Aggregates.SEND_TO_VOICEMAIL));
+        assertEquals(expectedSendToVoicemail ? 1 : 0, sendToVoicemail);
+        String ringtone = c.getString(c.getColumnIndex(Aggregates.CUSTOM_RINGTONE));
+        if (expectedRingtone == null) {
+            assertNull(ringtone);
+        } else {
+            assertTrue(ArrayUtils.contains(expectedRingtone.split(","), ringtone));
+        }
+        c.close();
+    }
+}
+
diff --git a/tests/src/com/android/providers/contacts/GroupsTest.java b/tests/src/com/android/providers/contacts/GroupsTest.java
new file mode 100644
index 0000000..a9428ae
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/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.contacts;
+
+import static com.android.providers.contacts.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.contacts.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/contacts/JaroWinklerDistanceTest.java b/tests/src/com/android/providers/contacts/JaroWinklerDistanceTest.java
new file mode 100644
index 0000000..ad34bba
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/JaroWinklerDistanceTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.contacts;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+import junit.framework.TestCase;
+
+@SmallTest
+public class JaroWinklerDistanceTest extends TestCase {
+
+    private JaroWinklerDistance mJaroWinklerDistance;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mJaroWinklerDistance = new JaroWinklerDistance(10);
+    }
+
+    public void testExactMatch() {
+        assertFloat(1, "Dwayne", "Dwayne");
+    }
+
+    public void testWinklerBonus() {
+        assertFloat(0.961f, "Martha", "Marhta");
+        assertFloat(0.840f, "Dwayne", "Duane");
+        assertFloat(0.813f, "DIXON", "DICKSONX");
+    }
+
+    public void testJaroDistance() {
+        assertFloat(0.600f, "Donny", "Duane");
+    }
+
+    public void testPoorMatch() {
+        assertFloat(0.467f, "Johny", "Duane");
+    }
+
+    public void testNoMatches() {
+        assertFloat(0, "Abcd", "Efgh");
+    }
+
+    private void assertFloat(float expected, String name1, String name2) {
+        byte[] s1 = Hex.decodeHex(NameNormalizer.normalize(name1));
+        byte[] s2 = Hex.decodeHex(NameNormalizer.normalize(name2));
+
+        float actual = mJaroWinklerDistance.getDistance(s1, s2);
+        assertTrue("Expected Jaro-Winkler distance: " + expected + ", actual: " + actual,
+                Math.abs(actual - expected) < 0.001);
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/NameNormalizerTest.java b/tests/src/com/android/providers/contacts/NameNormalizerTest.java
new file mode 100644
index 0000000..08d873f
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/NameNormalizerTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.contacts;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+import junit.framework.TestCase;
+
+/**
+ * Unit tests for {@link NameNormalizer}.
+ */
+@SmallTest
+public class NameNormalizerTest extends TestCase {
+
+    public void testDifferent() {
+        final String name1 = NameNormalizer.normalize("Helene");
+        final String name2 = NameNormalizer.normalize("Francesca");
+        assertFalse(name2.equals(name1));
+    }
+
+    public void testAccents() {
+        final String name1 = NameNormalizer.normalize("Helene");
+        final String name2 = NameNormalizer.normalize("H\u00e9l\u00e8ne");
+        assertTrue(name2.equals(name1));
+    }
+
+    public void testMixedCase() {
+        final String name1 = NameNormalizer.normalize("Helene");
+        final String name2 = NameNormalizer.normalize("hELENE");
+        assertTrue(name2.equals(name1));
+    }
+
+    public void testNonLetters() {
+        final String name1 = NameNormalizer.normalize("h-e?l e+n=e");
+        final String name2 = NameNormalizer.normalize("helene");
+        assertTrue(name2.equals(name1));
+    }
+
+    public void testComplexityCase() {
+        assertTrue(NameNormalizer.compareComplexity("Helene", "helene") > 0);
+    }
+
+    public void testComplexityAccent() {
+        assertTrue(NameNormalizer.compareComplexity("H\u00e9lene", "Helene") > 0);
+    }
+
+    public void testComplexityLength() {
+        assertTrue(NameNormalizer.compareComplexity("helene2009", "helene") > 0);
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/NameSplitterTest.java b/tests/src/com/android/providers/contacts/NameSplitterTest.java
new file mode 100644
index 0000000..91ce025
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/NameSplitterTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.contacts;
+
+import junit.framework.TestCase;
+
+import com.android.providers.contacts.NameSplitter.Name;
+
+import android.test.suitebuilder.annotation.SmallTest;
+
+/**
+ * Tests for {@link NameSplitter}.
+ */
+@SmallTest
+public class NameSplitterTest extends TestCase {
+    private NameSplitter mNameSplitter;
+    private Name mName;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mNameSplitter = new NameSplitter("Mr, Ms, Mrs", "d', st, st., von", "Jr, M.D., MD, D.D.S.",
+                "&, AND");
+        mName = new Name();
+    }
+
+    public void testNull() {
+        assertSplitName(null, null, null, null, null, null);
+    }
+
+    public void testEmpty() {
+        assertSplitName("", null, null, null, null, null);
+    }
+
+    public void testSpaces() {
+        assertSplitName(" ", null, null, null, null, null);
+    }
+
+    public void testLastName() {
+        assertSplitName("Smith", null, null, null, "Smith", null);
+    }
+
+    public void testFirstLastName() {
+        assertSplitName("John Smith", null, "John", null, "Smith", null);
+    }
+
+    public void testFirstMiddleLastName() {
+        assertSplitName("John Edward Smith", null, "John", "Edward", "Smith", null);
+    }
+
+    public void testThreeNamesAndLastName() {
+        assertSplitName("John Edward Kevin Smith", null, "John Edward", "Kevin", "Smith", null);
+    }
+
+    public void testPrefixFirstLastName() {
+        assertSplitName("Mr. John Smith", "Mr", "John", null, "Smith", null);
+        assertSplitName("Mr.John Smith", "Mr", "John", null, "Smith", null);
+    }
+
+    public void testFirstLastNameSuffix() {
+        assertSplitName("John Smith Jr.", null, "John", null, "Smith", "Jr");
+    }
+
+    public void testFirstLastNameSuffixWithDot() {
+        assertSplitName("John Smith M.D.", null, "John", null, "Smith", "M.D.");
+        assertSplitName("John Smith D D S", null, "John", null, "Smith", "D D S");
+    }
+
+    public void testFirstSuffixLastName() {
+        assertSplitName("John von Smith", null, "John", null, "von Smith", null);
+    }
+
+    public void testFirstSuffixLastNameWithDot() {
+        assertSplitName("John St.Smith", null, "John", null, "St. Smith", null);
+    }
+
+    public void testPrefixFirstMiddleLast() {
+        assertSplitName("Mr. John Kevin Smith", "Mr", "John", "Kevin", "Smith", null);
+        assertSplitName("Mr.John Kevin Smith", "Mr", "John", "Kevin", "Smith", null);
+    }
+
+    public void testPrefixFirstMiddleLastSuffix() {
+        assertSplitName("Mr. John Kevin Smith Jr.", "Mr", "John", "Kevin", "Smith", "Jr");
+    }
+
+    public void testPrefixFirstMiddlePrefixLastSuffixWrongCapitalization() {
+        assertSplitName("MR. john keVin VON SmiTh JR.", "MR", "john", "keVin", "VON SmiTh", "JR");
+    }
+
+    public void testPrefixLastSuffix() {
+        assertSplitName("von Smith Jr.", null, null, null, "von Smith", "Jr");
+    }
+
+    public void testTwoNamesAndLastNameWithAmpersand() {
+        assertSplitName("John & Edward Smith", null, "John & Edward", null, "Smith", null);
+        assertSplitName("John and Edward Smith", null, "John and Edward", null, "Smith", null);
+    }
+
+    public void testWithMiddleInitialAndNoDot() {
+        assertSplitName("John E. Smith", null, "John", "E", "Smith", null);
+    }
+
+    public void testWithLongFirstNameAndDot() {
+        assertSplitName("John Ed. K. Smith", null, "John Ed.", "K", "Smith", null);
+      }
+
+    private void assertSplitName(String fullName, String prefix, String givenNames,
+            String middleName, String lastName, String suffix) {
+        mNameSplitter.split(mName, fullName);
+        assertEquals(prefix, mName.getPrefix());
+        assertEquals(givenNames, mName.getGivenNames());
+        assertEquals(middleName, mName.getMiddleName());
+        assertEquals(lastName, mName.getFamilyName());
+        assertEquals(suffix, mName.getSuffix());
+    }
+}
diff --git a/tests/src/com/android/providers/contacts/RestrictionExceptionsTest.java b/tests/src/com/android/providers/contacts/RestrictionExceptionsTest.java
new file mode 100644
index 0000000..aad267f
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/RestrictionExceptionsTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.contacts;
+
+import static com.android.providers.contacts.ContactsActor.PACKAGE_BLUE;
+import static com.android.providers.contacts.ContactsActor.PACKAGE_GREEN;
+import static com.android.providers.contacts.ContactsActor.PACKAGE_GREY;
+import static com.android.providers.contacts.ContactsActor.PACKAGE_RED;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.BaseColumns;
+import android.provider.ContactsContract.Aggregates;
+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;
+
+/**
+ * Unit tests for {@link RestrictionExceptions}.
+ *
+ * Run the test like this:
+ * <code>
+ * adb shell am instrument -w \
+ *         com.android.providers.contacts.tests/android.test.InstrumentationTestRunner
+ * </code>
+ */
+@LargeTest
+public class RestrictionExceptionsTest extends AndroidTestCase {
+    private static final String TAG = "RestrictionExceptionsTest";
+
+    private static ContactsActor mGrey;
+    private static ContactsActor mRed;
+    private static ContactsActor mGreen;
+    private static ContactsActor mBlue;
+
+    private static final String PHONE_GREY = "555-1111";
+    private static final String PHONE_RED = "555-2222";
+    private static final String PHONE_GREEN = "555-3333";
+    private static final String PHONE_BLUE = "555-4444";
+
+    private static final String GENERIC_NAME = "Smith";
+
+    public RestrictionExceptionsTest() {
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        final Context overallContext = this.getContext();
+
+        // Build each of our specific actors in their own Contexts
+        mGrey = new ContactsActor(overallContext, PACKAGE_GREY);
+        mRed = new ContactsActor(overallContext, PACKAGE_RED);
+        mGreen = new ContactsActor(overallContext, PACKAGE_GREEN);
+        mBlue = new ContactsActor(overallContext, PACKAGE_BLUE);
+    }
+
+    /**
+     * Create various contacts that are both open and restricted, and assert
+     * that both {@link Contacts#IS_RESTRICTED} and
+     * {@link RestrictionExceptions} are being applied correctly.
+     */
+    public void testDataRestriction() {
+
+        // Clear all previous data before starting this test
+        mGrey.provider.wipeData();
+
+        // Grey creates an unprotected contact
+        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",
+                (mGrey.getDataCountForAggregate(greyAgg) == 1));
+        assertTrue("Non-owner of unrestricted contact unable to read",
+                (mBlue.getDataCountForAggregate(greyAgg) == 1));
+
+        // Red grants protected access to itself
+        mRed.updateException(PACKAGE_RED, PACKAGE_RED, true);
+
+        // Red creates a protected contact
+        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",
+                (mRed.getDataCountForAggregate(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mBlue.getDataCountForAggregate(redAgg) == 0));
+        assertTrue("Non-owner of restricted contact able to read",
+                (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.
+            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
+        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",
+                (mRed.getDataCountForAggregate(redAgg) == 1));
+        assertTrue("Non-owner with restriction exception unable to read",
+                (mBlue.getDataCountForAggregate(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mGreen.getDataCountForAggregate(redAgg) == 0));
+
+        // Red revokes exception to Blue
+        mRed.updateException(PACKAGE_RED, PACKAGE_BLUE, false);
+
+        // Assert that only Red can read contact
+        assertTrue("Owner of restricted contact unable to read",
+                (mRed.getDataCountForAggregate(redAgg) == 1));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mBlue.getDataCountForAggregate(redAgg) == 0));
+        assertTrue("Non-owner of restricted contact able to read",
+                (mGreen.getDataCountForAggregate(redAgg) == 0));
+
+    }
+
+    /**
+     * Create an aggregate that has multiple contacts with various levels of
+     * protected data, and ensure that {@link Aggregates#CONTENT_SUMMARY_URI}
+     * details don't expose {@link Contacts#IS_RESTRICTED} data.
+     */
+    public void testAggregateSummary() {
+
+        // Clear all previous data before starting this test
+        mGrey.provider.wipeData();
+
+        // Red grants exceptions to itself and Grey
+        mRed.updateException(PACKAGE_RED, PACKAGE_RED, true);
+        mRed.updateException(PACKAGE_RED, PACKAGE_GREY, true);
+
+        // Red creates a protected contact
+        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
+        mBlue.updateException(PACKAGE_BLUE, PACKAGE_BLUE, true);
+        mBlue.updateException(PACKAGE_BLUE, PACKAGE_GREY, true);
+
+        // Blue creates a protected contact
+        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
+        mRed.setSuperPrimaryPhone(redPhone);
+
+        // Make sure both aggregates were joined
+        long singleAgg;
+        {
+            long redAgg = mRed.getAggregateForContact(redContact);
+            long blueAgg = mBlue.getAggregateForContact(blueContact);
+            assertTrue("Two contacts with identical name not aggregated correctly",
+                    (redAgg == blueAgg));
+            singleAgg = redAgg;
+        }
+
+        // Grey and Red querying summary should see Red phone. Blue shouldn't
+        // 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",
+                (mGrey.getPrimaryPhoneId(singleAgg) == redPhone));
+        assertTrue("Participant with super-primary restricted data reading incorrect summary",
+                (mRed.getPrimaryPhoneId(singleAgg) == redPhone));
+        assertTrue("Participant with non-super-primary restricted data reading incorrect summary",
+                (mBlue.getPrimaryPhoneId(singleAgg) == 0));
+        assertTrue("Non-participant able to discover aggregate existance",
+                (mGreen.getPrimaryPhoneId(singleAgg) == 0));
+
+        // Add an unprotected Grey contact into the mix
+        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
+        mBlue.setSuperPrimaryPhone(bluePhone);
+
+        // Make sure all three aggregates were joined
+        {
+            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;
+        }
+
+        // Grey and Blue querying summary should see Blue phone. Red should see
+        // the Grey phone in its summary, since it's the unprotected fallback.
+        // 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",
+                (mGrey.getPrimaryPhoneId(singleAgg) == bluePhone));
+        assertTrue("Participant with non-super-primary restricted data reading incorrect summary",
+                (mRed.getPrimaryPhoneId(singleAgg) == greyPhone));
+        assertTrue("Participant with super-primary restricted data reading incorrect summary",
+                (mBlue.getPrimaryPhoneId(singleAgg) == bluePhone));
+        assertTrue("Non-participant couldn't find unrestricted primary through summary",
+                (mGreen.getPrimaryPhoneId(singleAgg) == greyPhone));
+
+    }
+
+    /**
+     * Create a contact that is completely restricted and isolated in its own
+     * aggregate, and make sure that another actor can't detect its existence.
+     */
+    public void testRestrictionSilence() {
+        Cursor cursor;
+
+        // Clear all previous data before starting this test
+        mGrey.provider.wipeData();
+
+        // Green grants exception to itself
+        mGreen.updateException(PACKAGE_GREEN, PACKAGE_GREEN, true);
+
+        // Green creates a protected contact
+        long greenContact = mGreen.createContact(true);
+        long greenData = mGreen.createPhone(greenContact, PHONE_GREEN);
+        long greenAgg = mGreen.getAggregateForContact(greenContact);
+
+        // AGGREGATES
+        cursor = mRed.resolver
+                .query(Aggregates.CONTENT_URI, Projections.PROJ_ID, null, null, null);
+        while (cursor.moveToNext()) {
+            assertTrue("Discovered restricted contact",
+                    (cursor.getLong(Projections.COL_ID) != greenAgg));
+        }
+        cursor.close();
+
+        // AGGREGATES_ID
+        cursor = mRed.resolver.query(ContentUris.withAppendedId(Aggregates.CONTENT_URI, greenAgg),
+                Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // AGGREGATES_DATA
+        cursor = mRed.resolver.query(Uri.withAppendedPath(ContentUris.withAppendedId(
+                Aggregates.CONTENT_URI, greenAgg), Aggregates.Data.CONTENT_DIRECTORY),
+                Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // AGGREGATES_SUMMARY
+        cursor = mRed.resolver.query(Aggregates.CONTENT_SUMMARY_URI, Projections.PROJ_ID, null,
+                null, null);
+        while (cursor.moveToNext()) {
+            assertTrue("Discovered restricted contact",
+                    (cursor.getLong(Projections.COL_ID) != greenAgg));
+        }
+        cursor.close();
+
+        // AGGREGATES_SUMMARY_ID
+        cursor = mRed.resolver.query(ContentUris.withAppendedId(Aggregates.CONTENT_SUMMARY_URI,
+                greenAgg), Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // TODO: AGGREGATES_SUMMARY_FILTER
+        // TODO: =========================
+
+        // TODO: AGGREGATION_SUGGESTIONS
+        // TODO: =======================
+
+        // CONTACTS
+        cursor = mRed.resolver.query(Contacts.CONTENT_URI, Projections.PROJ_ID, null, null, null);
+        while (cursor.moveToNext()) {
+            assertTrue("Discovered restricted contact",
+                    (cursor.getLong(Projections.COL_ID) != greenContact));
+        }
+        cursor.close();
+
+        // CONTACTS_ID
+        cursor = mRed.resolver.query(ContentUris
+                .withAppendedId(Contacts.CONTENT_URI, greenContact), Projections.PROJ_ID, null,
+                null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // CONTACTS_DATA
+        cursor = mRed.resolver.query(Uri.withAppendedPath(ContentUris.withAppendedId(
+                Contacts.CONTENT_URI, greenContact), Contacts.Data.CONTENT_DIRECTORY),
+                Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // TODO: CONTACTS_FILTER_EMAIL
+        // TODO: =====================
+
+        // DATA
+        cursor = mRed.resolver.query(Data.CONTENT_URI, Projections.PROJ_ID, null, null, null);
+        while (cursor.moveToNext()) {
+            assertTrue("Discovered restricted contact",
+                    (cursor.getLong(Projections.COL_ID) != greenData));
+        }
+        cursor.close();
+
+        // DATA_ID
+        cursor = mRed.resolver.query(ContentUris.withAppendedId(Data.CONTENT_URI, greenData),
+                Projections.PROJ_ID, null, null, null);
+        assertTrue("Discovered restricted contact", (cursor.getCount() == 0));
+        cursor.close();
+
+        // TODO: PHONE_LOOKUP
+        // TODO: ============
+
+    }
+
+    private interface Projections {
+        static final String[] PROJ_ID = new String[] {
+                BaseColumns._ID,
+        };
+
+        static final int COL_ID = 0;
+    }
+
+}
diff --git a/tests/src/com/android/providers/contacts/SynchronousContactsProvider2.java b/tests/src/com/android/providers/contacts/SynchronousContactsProvider2.java
new file mode 100644
index 0000000..fa3c2a6
--- /dev/null
+++ b/tests/src/com/android/providers/contacts/SynchronousContactsProvider2.java
@@ -0,0 +1,74 @@
+/*
+ * 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.contacts;
+
+import android.content.Context;
+
+/**
+ * A version of {@link ContactsProvider2} class that performs aggregation
+ * synchronously and wipes all data at construction time.
+ */
+public class SynchronousContactsProvider2 extends ContactsProvider2 {
+    private static Boolean sDataWiped = false;
+    private static OpenHelper mOpenHelper;
+
+    public SynchronousContactsProvider2() {
+        super(new SynchronousAggregationScheduler());
+    }
+
+    @Override
+    protected OpenHelper getOpenHelper(final Context context) {
+        if (mOpenHelper == null) {
+            mOpenHelper = new OpenHelper(context);
+        }
+        return mOpenHelper;
+    }
+
+    @Override
+    public boolean onCreate() {
+        boolean created = super.onCreate();
+        synchronized (sDataWiped) {
+            if (!sDataWiped) {
+                sDataWiped = true;
+                wipeData();
+            }
+        }
+        return created;
+    }
+
+    private static class SynchronousAggregationScheduler extends ContactAggregationScheduler {
+
+        @Override
+        public void start() {
+        }
+
+        @Override
+        public void stop() {
+        }
+
+        @Override
+        long currentTime() {
+            return 0;
+        }
+
+        @Override
+        void runDelayed() {
+            super.run();
+        }
+
+    }
+}